feat(memory): ingest Codex and Claude coding sessions (#4863)

Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
Steven Enamakel
2026-07-16 03:02:34 +03:00
committed by GitHub
co-authored by M3gA-Mind
parent 4be17632f0
commit 0b48b67099
70 changed files with 1983 additions and 156 deletions
+1 -1
View File
@@ -706,7 +706,7 @@ jobs:
- name: Init tinycortex submodule
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git submodule update --init vendor/tinycortex
git submodule update --init --recursive vendor/tinycortex
- name: Cache TinyCortex build artifacts
uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 1
submodules: true
submodules: recursive
- name: Install Rust
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Cargo
+1 -1
View File
@@ -401,7 +401,7 @@ jobs:
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves Rust SDK crates from vendor/.
- name: Init vendored Rust submodules
run: git submodule update --init vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
+1 -1
View File
@@ -297,7 +297,7 @@ jobs:
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves Rust SDK crates from vendor/.
- name: Init vendored Rust submodules
run: git submodule update --init vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image (no push)
+1 -1
View File
@@ -211,7 +211,7 @@ jobs:
- name: Init tinycortex submodule
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git submodule update --init vendor/tinycortex
git submodule update --init --recursive vendor/tinycortex
- name: Cache TinyCortex build artifacts
uses: Swatinem/rust-cache@v2
Generated
+20 -3
View File
@@ -4438,7 +4438,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 1.9.0",
"tinychannels",
"tinycortex",
"tinyflows",
@@ -6823,6 +6823,23 @@ dependencies = [
"tracing",
]
[[package]]
name = "tinyagents"
version = "2.0.0"
dependencies = [
"async-trait",
"bytes",
"chrono",
"futures",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "tinychannels"
version = "0.1.0"
@@ -6888,7 +6905,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 2.0.0",
"tokio",
"toml 0.8.23",
"tracing",
@@ -6908,7 +6925,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 1.9.0",
"tracing",
]
+1 -1
View File
@@ -88,7 +88,7 @@ tinyagents = { version = "1.7", features = ["sqlite", "repl"] }
# security gating, and the global singleton stay host-side. rusqlite/git2 are
# aligned to the host pins (=0.40 / 0.21) so one bundled SQLite + one libgit2
# link. Keep the version pin in lockstep with the submodule tag.
tinycortex = { version = "0.1", features = ["git-diff", "sync"] }
tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] }
tinychannels = { version = "0.1", features = ["relay-websocket"] }
# TokenJuice code compressor — AST-aware signature extraction. Optional (C build)
# behind the default `tokenjuice-treesitter` feature; disabling it falls back to
+1
View File
@@ -291,6 +291,7 @@ if should_run_suite "notifications"; then
echo "## Running suite: notifications"
run "test/e2e/specs/notifications.spec.ts" "notifications" "notifications"
run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" "notifications"
run "test/e2e/specs/coding-session-memory.spec.ts" "coding-session-memory" "notifications"
run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" "notifications"
run "test/e2e/specs/autocomplete-flow.spec.ts" "autocomplete" "notifications"
_mini_summary "notifications"
+20 -3
View File
@@ -5662,7 +5662,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 1.9.0",
"tinychannels",
"tinycortex",
"tinyflows",
@@ -9177,6 +9177,23 @@ dependencies = [
"tracing",
]
[[package]]
name = "tinyagents"
version = "2.0.0"
dependencies = [
"async-trait",
"bytes",
"chrono",
"futures",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "tinychannels"
version = "0.1.0"
@@ -9237,7 +9254,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 2.0.0",
"tokio",
"toml 0.8.2",
"tracing",
@@ -9257,7 +9274,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tinyagents",
"tinyagents 1.9.0",
"tracing",
]
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
type CodingSessionSourceStatus,
getCodingSessionStatus,
ingestCodingSessions,
} from '../../services/memorySourcesService';
import type { ToastNotification } from '../../types/intelligence';
import Button from '../ui/Button';
interface CodingSessionsCardProps {
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
}
const SOURCE_LABEL_KEYS: Record<CodingSessionSourceStatus['kind'], string> = {
claude_code: 'memorySources.codingSessions.claude',
codex: 'memorySources.codingSessions.codex',
};
export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
const { t } = useT();
const [sources, setSources] = useState<CodingSessionSourceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [ingesting, setIngesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
console.debug('[coding-sessions] status: entry');
setError(null);
try {
const next = await getCodingSessionStatus();
setSources(next);
console.debug('[coding-sessions] status: exit sources=%d', next.length);
} catch (cause) {
console.error('[coding-sessions] status failed', cause);
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const totals = useMemo(
() => ({
files: sources.reduce((sum, source) => sum + source.session_files, 0),
evidence: sources.reduce((sum, source) => sum + source.evidence_units, 0),
}),
[sources]
);
const hasImportableHistory =
totals.files > 0 || sources.some(source => source.scan_truncated === true);
const ingest = useCallback(async () => {
console.debug('[coding-sessions] ingest: entry');
setIngesting(true);
setError(null);
try {
const result = await ingestCodingSessions(false);
console.debug(
'[coding-sessions] ingest: exit processed=%d failed=%d budget_hit=%s',
result.sessions_processed,
result.sessions_failed,
result.budget_hit
);
const incomplete = result.sessions_failed > 0 || result.budget_hit;
onToast?.({
type: incomplete ? 'warning' : 'success',
title: t('memorySources.codingSessions.complete'),
message:
result.sessions_failed > 0
? t('memorySources.codingSessions.partialFailure')
.replace('{failed}', String(result.sessions_failed))
.replace('{processed}', String(result.sessions_processed))
: result.budget_hit
? t('memorySources.codingSessions.moreRemaining')
: t('memorySources.codingSessions.completeMessage')
.replace('{processed}', String(result.sessions_processed))
.replace('{observations}', String(result.observations)),
});
await load();
} catch (cause) {
console.error('[coding-sessions] ingest failed', cause);
const message = cause instanceof Error ? cause.message : String(cause);
setError(message);
onToast?.({ type: 'error', title: t('memorySources.codingSessions.failed'), message });
} finally {
setIngesting(false);
}
}, [load, onToast, t]);
return (
<section
className="rounded-lg border border-line bg-surface p-4"
data-testid="coding-sessions-card">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-content">
{t('memorySources.codingSessions.title')}
</h3>
<p className="mt-1 text-xs text-content-secondary">
{t('memorySources.codingSessions.description')}
</p>
</div>
<Button
analyticsId="brain-sources-coding-sessions-ingest"
size="sm"
onClick={() => void ingest()}
disabled={loading || ingesting || !hasImportableHistory}
data-testid="coding-sessions-ingest">
{ingesting
? t('memorySources.codingSessions.ingesting')
: t('memorySources.codingSessions.ingest')}
</Button>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{sources.map(source => (
<div
key={source.kind}
className="rounded-md border border-line-subtle bg-surface-secondary px-3 py-2"
data-testid={`coding-session-source-${source.kind}`}>
<div className="text-xs font-medium text-content">
{t(SOURCE_LABEL_KEYS[source.kind])}
</div>
<div className="mt-1 text-xs text-content-secondary">
{source.available
? t('memorySources.codingSessions.counts')
.replace('{files}', String(source.session_files))
.replace('{evidence}', String(source.evidence_units))
: t('memorySources.codingSessions.notFound')}
</div>
{source.available && source.scan_truncated && (
<div className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{t('memorySources.codingSessions.truncated')}
</div>
)}
</div>
))}
</div>
{loading && (
<p className="mt-3 text-xs text-content-secondary">
{t('memorySources.codingSessions.scanning')}
</p>
)}
{error && (
<p className="mt-3 text-xs text-coral-600 dark:text-coral-400" role="alert">
{error}
</p>
)}
</section>
);
}
@@ -0,0 +1,196 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as service from '../../../services/memorySourcesService';
import { renderWithProviders } from '../../../test/test-utils';
import { CodingSessionsCard } from '../CodingSessionsCard';
vi.mock('../../../services/memorySourcesService', async () => {
const actual = await vi.importActual<typeof import('../../../services/memorySourcesService')>(
'../../../services/memorySourcesService'
);
return { ...actual, getCodingSessionStatus: vi.fn(), ingestCodingSessions: vi.fn() };
});
const mockedStatus = vi.mocked(service.getCodingSessionStatus);
const mockedIngest = vi.mocked(service.ingestCodingSessions);
describe('CodingSessionsCard', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedStatus.mockResolvedValue([
{
kind: 'claude_code',
available: true,
session_files: 2,
evidence_units: 4,
invalid_files: 0,
},
{ kind: 'codex', available: true, session_files: 3, evidence_units: 7, invalid_files: 0 },
]);
});
it('shows discovered local session counts', async () => {
renderWithProviders(<CodingSessionsCard />);
expect(await screen.findByTestId('coding-session-source-claude_code')).toHaveTextContent(
'2 sessions · 4 human turns'
);
expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent(
'3 sessions · 7 human turns'
);
expect(screen.getByTestId('coding-sessions-ingest')).toBeEnabled();
expect(screen.getByTestId('coding-sessions-ingest')).toHaveAttribute(
'data-analytics-id',
'brain-sources-coding-sessions-ingest'
);
});
it('ingests incrementally and reports the distilled observations', async () => {
mockedIngest.mockResolvedValue({
mode: 'incremental',
files_seen: 5,
sessions_processed: 4,
sessions_skipped: 1,
sessions_failed: 0,
evidence_units: 11,
observations: 6,
budget_hit: false,
pack_path: '/workspace/persona/PERSONA.md',
});
const onToast = vi.fn();
renderWithProviders(<CodingSessionsCard onToast={onToast} />);
fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
await waitFor(() => expect(mockedIngest).toHaveBeenCalledWith(false));
await waitFor(() =>
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
message: '4 sessions produced 6 persona observations.',
})
)
);
});
it('keeps ingestion disabled when no human-authored evidence exists', async () => {
mockedStatus.mockResolvedValue([
{ kind: 'codex', available: false, session_files: 0, evidence_units: 0, invalid_files: 0 },
]);
renderWithProviders(<CodingSessionsCard />);
expect(await screen.findByText('No local history found')).toBeInTheDocument();
expect(screen.getByTestId('coding-sessions-ingest')).toBeDisabled();
});
it('warns when more coding sessions remain after the current batch', async () => {
mockedIngest.mockResolvedValue({
mode: 'incremental',
files_seen: 30,
sessions_processed: 15,
sessions_skipped: 0,
sessions_failed: 0,
evidence_units: 40,
observations: 20,
budget_hit: true,
pack_path: '/workspace/persona/PERSONA.md',
});
const onToast = vi.fn();
renderWithProviders(<CodingSessionsCard onToast={onToast} />);
fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
await waitFor(() =>
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
message:
'The session batch limit was reached. Run ingestion again to continue importing your history.',
})
)
);
});
it('reports partial session failures in the warning toast', async () => {
mockedIngest.mockResolvedValue({
mode: 'incremental',
files_seen: 5,
sessions_processed: 3,
sessions_skipped: 0,
sessions_failed: 2,
evidence_units: 8,
observations: 4,
budget_hit: false,
pack_path: '/workspace/persona/PERSONA.md',
});
const onToast = vi.fn();
renderWithProviders(<CodingSessionsCard onToast={onToast} />);
fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
await waitFor(() =>
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
message: '2 sessions failed while 3 were processed. Run ingestion again to retry them.',
})
)
);
});
it('shows status failures as an alert', async () => {
mockedStatus.mockRejectedValue(new Error('session scan failed'));
renderWithProviders(<CodingSessionsCard />);
expect(await screen.findByRole('alert')).toHaveTextContent('session scan failed');
});
it('reports ingestion failures through the error toast', async () => {
mockedIngest.mockRejectedValue(new Error('persona pipeline failed'));
const onToast = vi.fn();
renderWithProviders(<CodingSessionsCard onToast={onToast} />);
fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
await waitFor(() =>
expect(onToast).toHaveBeenCalledWith({
type: 'error',
title: 'Coding-session ingestion failed',
message: 'persona pipeline failed',
})
);
});
it('warns when a source scan reaches its file cap', async () => {
mockedStatus.mockResolvedValue([
{
kind: 'codex',
available: true,
session_files: 1000,
evidence_units: 1200,
invalid_files: 0,
scan_truncated: true,
},
]);
renderWithProviders(<CodingSessionsCard />);
expect(await screen.findByText('Scan limited to the first 1,000 session files.')).toBeVisible();
});
it('keeps ingestion enabled when a capped scan has not found evidence yet', async () => {
mockedStatus.mockResolvedValue([
{
kind: 'codex',
available: true,
session_files: 1000,
evidence_units: 0,
invalid_files: 1000,
scan_truncated: true,
},
]);
renderWithProviders(<CodingSessionsCard />);
expect(await screen.findByTestId('coding-sessions-ingest')).toBeEnabled();
});
});
+19
View File
@@ -7101,6 +7101,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'حذف',
'flows.delete.deleting': 'جارٍ الحذف…',
'flows.canvas.renameLabel': 'إعادة تسمية سير العمل',
'memorySources.codingSessions.title': 'جلسات وكلاء البرمجة',
'memorySources.codingSessions.description':
'حوّل قرارات وتصحيحات Codex وClaude Code إلى ذاكرة شخصية خاصة.',
'memorySources.codingSessions.ingest': 'استيعاب الجلسات الجديدة',
'memorySources.codingSessions.ingesting': 'جارٍ الاستيعاب…',
'memorySources.codingSessions.claude': 'كلود كود',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} جلسات · {evidence} مداخلات بشرية',
'memorySources.codingSessions.notFound': 'لم يُعثر على سجل محلي',
'memorySources.codingSessions.scanning': 'جارٍ فحص سجل الجلسات المحلي…',
'memorySources.codingSessions.truncated': 'اقتصر الفحص على أول 1,000 ملف جلسة.',
'memorySources.codingSessions.complete': 'تم استيعاب جلسات البرمجة',
'memorySources.codingSessions.completeMessage':
'أنتجت {processed} جلسات {observations} ملاحظات شخصية.',
'memorySources.codingSessions.partialFailure':
'فشلت {failed} جلسات بينما تمت معالجة {processed}. شغّل الاستيعاب مرة أخرى لإعادة المحاولة.',
'memorySources.codingSessions.moreRemaining':
'تم بلوغ حد دفعة الجلسات. شغّل الاستيعاب مرة أخرى لمتابعة استيراد سجلك.',
'memorySources.codingSessions.failed': 'فشل استيعاب جلسات البرمجة',
'flows.canvas.sidePanelToggle': 'اللوحة الجانبية',
'flows.canvas.legendTab': 'يدوي',
+19
View File
@@ -7269,6 +7269,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'মুছুন',
'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…',
'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন',
'memorySources.codingSessions.title': 'কোডিং-এজেন্ট সেশন',
'memorySources.codingSessions.description':
'Codex ও Claude Code-এর সিদ্ধান্ত এবং সংশোধনকে ব্যক্তিগত পারসোনা মেমরিতে রূপ দিন।',
'memorySources.codingSessions.ingest': 'নতুন সেশন গ্রহণ করুন',
'memorySources.codingSessions.ingesting': 'গ্রহণ করা হচ্ছে…',
'memorySources.codingSessions.claude': 'ক্লড কোড',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files}টি সেশন · {evidence}টি মানব বার্তা',
'memorySources.codingSessions.notFound': 'কোনো স্থানীয় ইতিহাস পাওয়া যায়নি',
'memorySources.codingSessions.scanning': 'স্থানীয় সেশন ইতিহাস স্ক্যান করা হচ্ছে…',
'memorySources.codingSessions.truncated': 'স্ক্যানটি প্রথম ১,০০০টি সেশন ফাইলে সীমাবদ্ধ ছিল।',
'memorySources.codingSessions.complete': 'কোডিং সেশন গ্রহণ সম্পন্ন',
'memorySources.codingSessions.completeMessage':
'{processed}টি সেশন থেকে {observations}টি পারসোনা পর্যবেক্ষণ তৈরি হয়েছে।',
'memorySources.codingSessions.partialFailure':
'{processed}টি সেশন প্রক্রিয়া করার সময় {failed}টি ব্যর্থ হয়েছে। আবার চেষ্টা করতে গ্রহণ পুনরায় চালান।',
'memorySources.codingSessions.moreRemaining':
'সেশন ব্যাচের সীমা পূর্ণ হয়েছে। আপনার ইতিহাস আমদানি চালিয়ে যেতে আবার গ্রহণ চালান।',
'memorySources.codingSessions.failed': 'কোডিং সেশন গ্রহণ ব্যর্থ হয়েছে',
'flows.canvas.sidePanelToggle': 'সাইড প্যানেল',
'flows.canvas.legendTab': 'ম্যানুয়াল',
+20
View File
@@ -7482,6 +7482,26 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Löschen',
'flows.delete.deleting': 'Wird gelöscht…',
'flows.canvas.renameLabel': 'Workflow umbenennen',
'memorySources.codingSessions.title': 'Coding-Agent-Sitzungen',
'memorySources.codingSessions.description':
'Verwandle Entscheidungen und Korrekturen aus Codex und Claude Code in private Persona-Erinnerungen.',
'memorySources.codingSessions.ingest': 'Neue Sitzungen einlesen',
'memorySources.codingSessions.ingesting': 'Wird eingelesen…',
'memorySources.codingSessions.claude': 'Claude-Code-Verlauf',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} Sitzungen · {evidence} menschliche Beiträge',
'memorySources.codingSessions.notFound': 'Kein lokaler Verlauf gefunden',
'memorySources.codingSessions.scanning': 'Lokaler Sitzungsverlauf wird durchsucht…',
'memorySources.codingSessions.truncated':
'Der Scan wurde auf die ersten 1.000 Sitzungsdateien begrenzt.',
'memorySources.codingSessions.complete': 'Coding-Sitzungen eingelesen',
'memorySources.codingSessions.completeMessage':
'{processed} Sitzungen ergaben {observations} Persona-Beobachtungen.',
'memorySources.codingSessions.partialFailure':
'{failed} Sitzungen sind fehlgeschlagen, während {processed} verarbeitet wurden. Starten Sie das Einlesen erneut.',
'memorySources.codingSessions.moreRemaining':
'Das Sitzungslimit für diesen Durchlauf wurde erreicht. Starten Sie das Einlesen erneut, um den Import fortzusetzen.',
'memorySources.codingSessions.failed': 'Einlesen der Coding-Sitzungen fehlgeschlagen',
'flows.canvas.sidePanelToggle': 'Seitenleiste',
'flows.canvas.legendTab': 'Manuell',
+19
View File
@@ -7588,6 +7588,25 @@ const en: TranslationMap = {
'Your AI provider has no API key set. Add one in provider settings to continue.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Scheduled job',
'memorySources.codingSessions.title': 'Coding-agent sessions',
'memorySources.codingSessions.description':
'Turn your Codex and Claude Code decisions and corrections into private persona memory.',
'memorySources.codingSessions.ingest': 'Ingest new sessions',
'memorySources.codingSessions.ingesting': 'Ingesting…',
'memorySources.codingSessions.claude': 'Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sessions · {evidence} human turns',
'memorySources.codingSessions.notFound': 'No local history found',
'memorySources.codingSessions.scanning': 'Scanning local session history…',
'memorySources.codingSessions.truncated': 'Scan limited to the first 1,000 session files.',
'memorySources.codingSessions.complete': 'Coding sessions ingested',
'memorySources.codingSessions.completeMessage':
'{processed} sessions produced {observations} persona observations.',
'memorySources.codingSessions.partialFailure':
'{failed} sessions failed while {processed} were processed. Run ingestion again to retry them.',
'memorySources.codingSessions.moreRemaining':
'The session batch limit was reached. Run ingestion again to continue importing your history.',
'memorySources.codingSessions.failed': 'Coding-session ingestion failed',
};
export default en;
+20
View File
@@ -7420,6 +7420,26 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Eliminar',
'flows.delete.deleting': 'Eliminando…',
'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo',
'memorySources.codingSessions.title': 'Sesiones de agentes de programación',
'memorySources.codingSessions.description':
'Convierte tus decisiones y correcciones de Codex y Claude Code en memoria privada de personalidad.',
'memorySources.codingSessions.ingest': 'Ingerir sesiones nuevas',
'memorySources.codingSessions.ingesting': 'Ingiriendo…',
'memorySources.codingSessions.claude': 'Historial de Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sesiones · {evidence} intervenciones humanas',
'memorySources.codingSessions.notFound': 'No se encontró historial local',
'memorySources.codingSessions.scanning': 'Buscando historial local de sesiones…',
'memorySources.codingSessions.truncated':
'El análisis se limitó a los primeros 1000 archivos de sesión.',
'memorySources.codingSessions.complete': 'Sesiones de programación ingeridas',
'memorySources.codingSessions.completeMessage':
'{processed} sesiones produjeron {observations} observaciones de personalidad.',
'memorySources.codingSessions.partialFailure':
'Fallaron {failed} sesiones mientras se procesaron {processed}. Ejecuta la ingesta de nuevo para reintentarlas.',
'memorySources.codingSessions.moreRemaining':
'Se alcanzó el límite de sesiones del lote. Ejecuta la ingesta de nuevo para seguir importando tu historial.',
'memorySources.codingSessions.failed': 'Falló la ingesta de sesiones de programación',
'flows.canvas.sidePanelToggle': 'Panel lateral',
'flows.canvas.legendTab': 'Manual',
+20
View File
@@ -7455,6 +7455,26 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Supprimer',
'flows.delete.deleting': 'Suppression…',
'flows.canvas.renameLabel': 'Renommer le workflow',
'memorySources.codingSessions.title': 'Sessions dagents de programmation',
'memorySources.codingSessions.description':
'Transformez vos décisions et corrections Codex et Claude Code en mémoire de persona privée.',
'memorySources.codingSessions.ingest': 'Ingérer les nouvelles sessions',
'memorySources.codingSessions.ingesting': 'Ingestion…',
'memorySources.codingSessions.claude': 'Historique Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sessions · {evidence} interventions humaines',
'memorySources.codingSessions.notFound': 'Aucun historique local trouvé',
'memorySources.codingSessions.scanning': 'Analyse de lhistorique local…',
'memorySources.codingSessions.truncated':
'Lanalyse a été limitée aux 1 000 premiers fichiers de session.',
'memorySources.codingSessions.complete': 'Sessions de programmation ingérées',
'memorySources.codingSessions.completeMessage':
'{processed} sessions ont produit {observations} observations de persona.',
'memorySources.codingSessions.partialFailure':
'{failed} sessions ont échoué tandis que {processed} ont été traitées. Relancez lingestion pour réessayer.',
'memorySources.codingSessions.moreRemaining':
'La limite de sessions du lot a été atteinte. Relancez lingestion pour continuer à importer votre historique.',
'memorySources.codingSessions.failed': 'Échec de lingestion des sessions de programmation',
'flows.canvas.sidePanelToggle': 'Panneau latéral',
'flows.canvas.legendTab': 'Manuel',
+19
View File
@@ -7265,6 +7265,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'हटाएं',
'flows.delete.deleting': 'हटाया जा रहा है…',
'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें',
'memorySources.codingSessions.title': 'कोडिंग-एजेंट सत्र',
'memorySources.codingSessions.description':
'Codex और Claude Code के निर्णयों व सुधारों को निजी व्यक्तित्व स्मृति में बदलें।',
'memorySources.codingSessions.ingest': 'नए सत्र शामिल करें',
'memorySources.codingSessions.ingesting': 'शामिल किया जा रहा है…',
'memorySources.codingSessions.claude': 'क्लॉड कोड',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} सत्र · {evidence} मानवीय संदेश',
'memorySources.codingSessions.notFound': 'कोई स्थानीय इतिहास नहीं मिला',
'memorySources.codingSessions.scanning': 'स्थानीय सत्र इतिहास स्कैन हो रहा है…',
'memorySources.codingSessions.truncated': 'स्कैन पहले 1,000 सत्र फ़ाइलों तक सीमित था।',
'memorySources.codingSessions.complete': 'कोडिंग सत्र शामिल हो गए',
'memorySources.codingSessions.completeMessage':
'{processed} सत्रों से {observations} व्यक्तित्व अवलोकन बने।',
'memorySources.codingSessions.partialFailure':
'{processed} सत्र संसाधित हुए, जबकि {failed} विफल रहे। दोबारा प्रयास करने के लिए अंतर्ग्रहण फिर चलाएँ।',
'memorySources.codingSessions.moreRemaining':
'सत्र बैच की सीमा पूरी हो गई है। अपना इतिहास आयात करना जारी रखने के लिए फिर से अंतर्ग्रहण चलाएँ।',
'memorySources.codingSessions.failed': 'कोडिंग सत्र शामिल करना विफल रहा',
'flows.canvas.sidePanelToggle': 'साइड पैनल',
'flows.canvas.legendTab': 'मैनुअल',
+19
View File
@@ -7303,6 +7303,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Hapus',
'flows.delete.deleting': 'Menghapus…',
'flows.canvas.renameLabel': 'Ganti nama alur kerja',
'memorySources.codingSessions.title': 'Sesi agen pemrograman',
'memorySources.codingSessions.description':
'Ubah keputusan dan koreksi Codex serta Claude Code menjadi memori persona pribadi.',
'memorySources.codingSessions.ingest': 'Serap sesi baru',
'memorySources.codingSessions.ingesting': 'Menyerap…',
'memorySources.codingSessions.claude': 'Riwayat Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sesi · {evidence} masukan manusia',
'memorySources.codingSessions.notFound': 'Riwayat lokal tidak ditemukan',
'memorySources.codingSessions.scanning': 'Memindai riwayat sesi lokal…',
'memorySources.codingSessions.truncated': 'Pemindaian dibatasi pada 1.000 file sesi pertama.',
'memorySources.codingSessions.complete': 'Sesi pemrograman telah diserap',
'memorySources.codingSessions.completeMessage':
'{processed} sesi menghasilkan {observations} pengamatan persona.',
'memorySources.codingSessions.partialFailure':
'{failed} sesi gagal sementara {processed} berhasil diproses. Jalankan penyerapan lagi untuk mencoba ulang.',
'memorySources.codingSessions.moreRemaining':
'Batas batch sesi tercapai. Jalankan penyerapan lagi untuk melanjutkan impor riwayat Anda.',
'memorySources.codingSessions.failed': 'Gagal menyerap sesi pemrograman',
'flows.canvas.sidePanelToggle': 'Panel samping',
'flows.canvas.legendTab': 'Manual',
+21
View File
@@ -7408,6 +7408,27 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Elimina',
'flows.delete.deleting': 'Eliminazione…',
'flows.canvas.renameLabel': 'Rinomina flusso di lavoro',
'memorySources.codingSessions.title': 'Sessioni degli agenti di programmazione',
'memorySources.codingSessions.description':
'Trasforma decisioni e correzioni di Codex e Claude Code in memoria privata della persona.',
'memorySources.codingSessions.ingest': 'Acquisisci nuove sessioni',
'memorySources.codingSessions.ingesting': 'Acquisizione…',
'memorySources.codingSessions.claude': 'Cronologia Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sessioni · {evidence} interventi umani',
'memorySources.codingSessions.notFound': 'Nessuna cronologia locale trovata',
'memorySources.codingSessions.scanning': 'Scansione della cronologia locale…',
'memorySources.codingSessions.truncated':
'La scansione è stata limitata ai primi 1.000 file di sessione.',
'memorySources.codingSessions.complete': 'Sessioni di programmazione acquisite',
'memorySources.codingSessions.completeMessage':
'{processed} sessioni hanno prodotto {observations} osservazioni della persona.',
'memorySources.codingSessions.partialFailure':
'{failed} sessioni non sono riuscite mentre {processed} sono state elaborate. Avvia di nuovo lacquisizione per riprovare.',
'memorySources.codingSessions.moreRemaining':
'È stato raggiunto il limite di sessioni del batch. Avvia di nuovo lacquisizione per continuare a importare la cronologia.',
'memorySources.codingSessions.failed':
'Acquisizione delle sessioni di programmazione non riuscita',
'flows.canvas.sidePanelToggle': 'Pannello laterale',
'flows.canvas.legendTab': 'Manuale',
+19
View File
@@ -7180,6 +7180,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': '삭제',
'flows.delete.deleting': '삭제 중…',
'flows.canvas.renameLabel': '워크플로 이름 바꾸기',
'memorySources.codingSessions.title': '코딩 에이전트 세션',
'memorySources.codingSessions.description':
'Codex와 Claude Code의 결정 및 수정 사항을 비공개 페르소나 메모리로 변환합니다.',
'memorySources.codingSessions.ingest': '새 세션 수집',
'memorySources.codingSessions.ingesting': '수집 중…',
'memorySources.codingSessions.claude': '클로드 코드',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '세션 {files}개 · 사용자 입력 {evidence}개',
'memorySources.codingSessions.notFound': '로컬 기록을 찾지 못했습니다',
'memorySources.codingSessions.scanning': '로컬 세션 기록을 검색하는 중…',
'memorySources.codingSessions.truncated': '스캔이 처음 1,000개 세션 파일로 제한되었습니다.',
'memorySources.codingSessions.complete': '코딩 세션 수집 완료',
'memorySources.codingSessions.completeMessage':
'세션 {processed}개에서 페르소나 관찰 {observations}개를 만들었습니다.',
'memorySources.codingSessions.partialFailure':
'세션 {processed}개를 처리하는 동안 {failed}개가 실패했습니다. 다시 시도하려면 수집을 다시 실행하세요.',
'memorySources.codingSessions.moreRemaining':
'세션 배치 한도에 도달했습니다. 기록 가져오기를 계속하려면 수집을 다시 실행하세요.',
'memorySources.codingSessions.failed': '코딩 세션 수집 실패',
'flows.canvas.sidePanelToggle': '사이드 패널',
'flows.canvas.legendTab': '수동',
+20
View File
@@ -7378,6 +7378,26 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Usuń',
'flows.delete.deleting': 'Usuwanie…',
'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy',
'memorySources.codingSessions.title': 'Sesje agentów programistycznych',
'memorySources.codingSessions.description':
'Zamień decyzje i poprawki z Codex oraz Claude Code w prywatną pamięć persony.',
'memorySources.codingSessions.ingest': 'Wczytaj nowe sesje',
'memorySources.codingSessions.ingesting': 'Wczytywanie…',
'memorySources.codingSessions.claude': 'Historia Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sesji · {evidence} wypowiedzi użytkownika',
'memorySources.codingSessions.notFound': 'Nie znaleziono lokalnej historii',
'memorySources.codingSessions.scanning': 'Skanowanie lokalnej historii sesji…',
'memorySources.codingSessions.truncated':
'Skanowanie ograniczono do pierwszych 1000 plików sesji.',
'memorySources.codingSessions.complete': 'Sesje programistyczne wczytane',
'memorySources.codingSessions.completeMessage':
'{processed} sesji utworzyło {observations} obserwacji persony.',
'memorySources.codingSessions.partialFailure':
'Nie udało się przetworzyć {failed} sesji, a {processed} przetworzono. Uruchom import ponownie, aby spróbować jeszcze raz.',
'memorySources.codingSessions.moreRemaining':
'Osiągnięto limit sesji w partii. Uruchom import ponownie, aby kontynuować wczytywanie historii.',
'memorySources.codingSessions.failed': 'Nie udało się wczytać sesji programistycznych',
'flows.canvas.sidePanelToggle': 'Panel boczny',
'flows.canvas.legendTab': 'Ręczny',
+20
View File
@@ -7389,6 +7389,26 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Excluir',
'flows.delete.deleting': 'Excluindo…',
'flows.canvas.renameLabel': 'Renomear fluxo de trabalho',
'memorySources.codingSessions.title': 'Sessões de agentes de programação',
'memorySources.codingSessions.description':
'Transforme decisões e correções do Codex e Claude Code em memória privada de persona.',
'memorySources.codingSessions.ingest': 'Ingerir novas sessões',
'memorySources.codingSessions.ingesting': 'Ingerindo…',
'memorySources.codingSessions.claude': 'Histórico do Claude Code',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} sessões · {evidence} mensagens humanas',
'memorySources.codingSessions.notFound': 'Nenhum histórico local encontrado',
'memorySources.codingSessions.scanning': 'Verificando o histórico local…',
'memorySources.codingSessions.truncated':
'A verificação foi limitada aos primeiros 1.000 arquivos de sessão.',
'memorySources.codingSessions.complete': 'Sessões de programação ingeridas',
'memorySources.codingSessions.completeMessage':
'{processed} sessões produziram {observations} observações de persona.',
'memorySources.codingSessions.partialFailure':
'{failed} sessões falharam enquanto {processed} foram processadas. Execute a ingestão novamente para tentar de novo.',
'memorySources.codingSessions.moreRemaining':
'O limite de sessões do lote foi atingido. Execute a ingestão novamente para continuar importando seu histórico.',
'memorySources.codingSessions.failed': 'Falha ao ingerir sessões de programação',
'flows.canvas.sidePanelToggle': 'Painel lateral',
'flows.canvas.legendTab': 'Manual',
+19
View File
@@ -7350,6 +7350,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Удалить',
'flows.delete.deleting': 'Удаление…',
'flows.canvas.renameLabel': 'Переименовать рабочий процесс',
'memorySources.codingSessions.title': 'Сеансы агентов программирования',
'memorySources.codingSessions.description':
'Превратите решения и исправления из Codex и Claude Code в приватную память персоны.',
'memorySources.codingSessions.ingest': 'Загрузить новые сеансы',
'memorySources.codingSessions.ingesting': 'Загрузка…',
'memorySources.codingSessions.claude': 'Клод Код',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': 'Сеансы: {files} · Сообщения пользователя: {evidence}',
'memorySources.codingSessions.notFound': 'Локальная история не найдена',
'memorySources.codingSessions.scanning': 'Сканирование локальной истории…',
'memorySources.codingSessions.truncated': 'Сканирование ограничено первыми 1000 файлами сеансов.',
'memorySources.codingSessions.complete': 'Сеансы программирования загружены',
'memorySources.codingSessions.completeMessage':
'Обработано сеансов: {processed}; наблюдений персоны: {observations}.',
'memorySources.codingSessions.partialFailure':
'Не удалось обработать сеансов: {failed}; обработано: {processed}. Запустите загрузку ещё раз для повтора.',
'memorySources.codingSessions.moreRemaining':
'Достигнут лимит сеансов в пакете. Запустите загрузку ещё раз, чтобы продолжить импорт истории.',
'memorySources.codingSessions.failed': 'Не удалось загрузить сеансы программирования',
'flows.canvas.sidePanelToggle': 'Боковая панель',
'flows.canvas.legendTab': 'Вручную',
+19
View File
@@ -6869,6 +6869,25 @@ const messages: TranslationMap = {
'flows.delete.confirm': '删除',
'flows.delete.deleting': '正在删除…',
'flows.canvas.renameLabel': '重命名工作流',
'memorySources.codingSessions.title': '编程智能体会话',
'memorySources.codingSessions.description':
'将 Codex 和 Claude Code 中的决策与纠正转化为私有人格记忆。',
'memorySources.codingSessions.ingest': '摄取新会话',
'memorySources.codingSessions.ingesting': '正在摄取…',
'memorySources.codingSessions.claude': '克劳德代码',
'memorySources.codingSessions.codex': 'Codex',
'memorySources.codingSessions.counts': '{files} 个会话 · {evidence} 条证据',
'memorySources.codingSessions.notFound': '未找到本地历史记录',
'memorySources.codingSessions.scanning': '正在扫描本地会话历史…',
'memorySources.codingSessions.truncated': '扫描仅限前 1,000 个会话文件。',
'memorySources.codingSessions.complete': '编程会话已摄取',
'memorySources.codingSessions.completeMessage':
'{processed} 个会话生成了 {observations} 条人格观察。',
'memorySources.codingSessions.partialFailure':
'{processed} 个会话已处理,{failed} 个失败。请再次运行摄取以重试。',
'memorySources.codingSessions.moreRemaining':
'已达到本批次的会话上限。请再次运行摄取以继续导入历史记录。',
'memorySources.codingSessions.failed': '编程会话摄取失败',
'flows.canvas.sidePanelToggle': '侧边栏',
'flows.canvas.legendTab': '手动',
+2
View File
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { CodingSessionsCard } from '../components/intelligence/CodingSessionsCard';
import GoalsPanel from '../components/intelligence/GoalsPanel';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { MemoryControls } from '../components/intelligence/MemoryControls';
@@ -293,6 +294,7 @@ export default function Brain() {
{activeTab === 'sources' && (
<div className="space-y-5 animate-fade-up">
<CodingSessionsCard onToast={addToast} />
<MemorySourcesRegistry onToast={addToast} />
</div>
)}
@@ -4,6 +4,8 @@ import { callCoreRpc } from './coreRpcClient';
import {
addMemorySource,
applyAllIn,
getCodingSessionStatus,
ingestCodingSessions,
listMemorySources,
type MemorySourceEntry,
removeMemorySource,
@@ -190,4 +192,47 @@ describe('memorySourcesService', () => {
expect(entry.max_tokens_per_sync).toBe(100_000);
expect(entry.max_cost_per_sync_usd).toBe(1.5);
});
it('discovers Codex and Claude Code session sources', async () => {
mockedCall.mockResolvedValue({
result: {
sources: [
{ kind: 'codex', available: true, session_files: 2, evidence_units: 5, invalid_files: 0 },
],
},
logs: [],
} as never);
const sources = await getCodingSessionStatus();
expect(mockedCall).toHaveBeenCalledWith({
method: 'openhuman.memory_sources_coding_session_status',
});
expect(sources[0]).toMatchObject({ kind: 'codex', evidence_units: 5 });
});
it('requests a timeout-aligned incremental coding-session batch', async () => {
mockedCall.mockResolvedValue({
result: {
mode: 'incremental',
files_seen: 2,
sessions_processed: 2,
sessions_skipped: 0,
sessions_failed: 0,
evidence_units: 5,
observations: 3,
budget_hit: false,
},
logs: [],
} as never);
const result = await ingestCodingSessions(false, 25);
expect(mockedCall).toHaveBeenCalledWith({
method: 'openhuman.memory_sources_ingest_coding_sessions',
params: { backfill: false, max_sessions: 15 },
timeoutMs: 585_000,
});
expect(result.sessions_processed).toBe(2);
});
});
+72
View File
@@ -201,6 +201,78 @@ export async function applyAllIn(): Promise<ApplyAllInResult> {
return { sources: data.sources ?? [], sync_triggered: data.sync_triggered ?? 0 };
}
export interface CodingSessionSourceStatus {
kind: 'claude_code' | 'codex';
available: boolean;
session_files: number;
evidence_units: number;
invalid_files: number;
scan_truncated?: boolean;
}
export interface CodingSessionIngestResult {
mode: 'backfill' | 'incremental';
files_seen: number;
sessions_processed: number;
sessions_skipped: number;
sessions_failed: number;
evidence_units: number;
observations: number;
budget_hit: boolean;
pack_path?: string | null;
}
// Keep interactive imports below the core RPC client's bounded ten-minute
// ceiling. Larger histories are intentionally processed in repeatable batches;
// `budget_hit` tells the card to invite the user to continue.
const CODING_SESSION_BATCH_MAX = 15;
const CODING_SESSION_BASE_TIMEOUT_MS = 120_000;
const CODING_SESSION_PER_SESSION_TIMEOUT_MS = 30_000;
const CODING_SESSION_RPC_GRACE_MS = 15_000;
export async function getCodingSessionStatus(): Promise<CodingSessionSourceStatus[]> {
log('coding_session_status: entry');
const resp = await callCoreRpc<{ sources: CodingSessionSourceStatus[] }>({
method: 'openhuman.memory_sources_coding_session_status',
});
const data = unwrap<{ sources: CodingSessionSourceStatus[] }>(resp);
log('coding_session_status: exit sources=%d', data.sources?.length ?? 0);
return data.sources ?? [];
}
export async function ingestCodingSessions(
backfill = false,
maxSessions = CODING_SESSION_BATCH_MAX
): Promise<CodingSessionIngestResult> {
const boundedMaxSessions = Number.isFinite(maxSessions)
? Math.min(Math.max(Math.trunc(maxSessions), 1), CODING_SESSION_BATCH_MAX)
: CODING_SESSION_BATCH_MAX;
const timeoutMs =
CODING_SESSION_BASE_TIMEOUT_MS +
boundedMaxSessions * CODING_SESSION_PER_SESSION_TIMEOUT_MS +
CODING_SESSION_RPC_GRACE_MS;
log(
'ingest_coding_sessions: entry backfill=%s max_sessions=%d requested=%d timeout_ms=%d',
backfill,
boundedMaxSessions,
maxSessions,
timeoutMs
);
const resp = await callCoreRpc<CodingSessionIngestResult>({
method: 'openhuman.memory_sources_ingest_coding_sessions',
params: { backfill, max_sessions: boundedMaxSessions },
timeoutMs,
});
const data = unwrap<CodingSessionIngestResult>(resp);
log(
'ingest_coding_sessions: exit processed=%d failed=%d budget_hit=%s',
data.sessions_processed,
data.sessions_failed,
data.budget_hit
);
return data;
}
/// i18n keys for each source kind's user-visible label. Resolve via
/// `t(SOURCE_KIND_LABEL_KEYS[kind])` in components — keeping the keys
/// as a constant lets the dialog kind-picker render the same labels
@@ -0,0 +1,18 @@
import { waitForApp } from '../helpers/app-helpers';
import { navigateViaHash } from '../helpers/shared-flows';
describe('Coding-agent session memory', () => {
before(async () => {
await waitForApp();
await navigateViaHash('/brain?tab=sources');
});
it('surfaces Codex and Claude Code as private local memory sources', async () => {
const card = await $('[data-testid="coding-sessions-card"]');
await card.waitForDisplayed({ timeout: 20_000 });
expect(await card.getText()).toContain('Coding-agent sessions');
await expect($('[data-testid="coding-session-source-claude_code"]')).toBeDisplayed();
await expect($('[data-testid="coding-session-source-codex"]')).toBeDisplayed();
await expect($('[data-testid="coding-sessions-ingest"]')).toBeDisplayed();
});
});
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
import { bootAuthenticatedPage, waitForAppReady } from '../helpers/core-rpc';
test.describe('Coding-agent session memory', () => {
test('shows Codex and Claude Code discovery on the Brain sources page', async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-coding-session-memory', '/brain?tab=sources');
await waitForAppReady(page);
const card = page.getByTestId('coding-sessions-card');
await expect(card).toBeVisible({ timeout: 20_000 });
await expect(card).toContainText('Coding-agent sessions');
await expect(page.getByTestId('coding-session-source-claude_code')).toBeVisible();
await expect(page.getByTestId('coding-session-source-codex')).toBeVisible();
await expect(page.getByTestId('coding-sessions-ingest')).toBeVisible();
});
});
+42 -41
View File
@@ -187,7 +187,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
| 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up |
| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up |
| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up |
### 4.3 Tool Invocation
@@ -280,27 +280,27 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 6.3 Sub-agent Orchestration
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ---------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. |
| 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. |
| 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. |
| 6.3.4 | Subconscious trigger pipeline (normalize → dedupe/rate → gate → queue) | RU+RI | `src/openhuman/subconscious_triggers/`, `tests/subconscious_triggers_e2e.rs` | ✅ | Event→Trigger normalization for cron/user/composio/sub-agent, dedupe TTL + per-source rate limit, LLM gate over `agent::triage`, priority queue with overflow eviction. |
| 6.3.5 | Long-lived subconscious orchestrator session | RU | `src/openhuman/subconscious/session.rs`, `src/openhuman/subconscious/user_thread.rs` | ✅ | Persistent compressed session backed by a reserved thread; `notify_user` handoff to the user-facing thread; mode→autonomy config parity. |
| 6.3.6 | Multi-party human↔subconscious↔sub-agent conversation | RI | `tests/subconscious_conversation_e2e.rs` | ✅ | Scripted Gate/SessionExecutor seam drives delegate→sub-agent→merge, failure/retry, interleaving, dedupe, and rate-limit scenarios through the real orchestrator. |
| 6.3.7 | Full-stack trigger pipeline with mocked LLM | RI | `tests/subconscious_fullstack_e2e.rs` (feature `e2e-test-support`) | ✅ | Real `GatePass`+`LongLivedSession`+`Agent`+sub-agent run against a provider-layer mock (no network); promote/drop, persistence, real `spawn_subagent`. |
| 6.3.8 | Subconscious Triggers debug/manage panel (Brain) | WD | `app/test/playwright/specs/subconscious-triggers.spec.ts` | ✅ | Brain→Subconscious panel: renders disabled baseline + hint + reserved thread ids; enable toggle → Pipeline Enabled + event_driven + orchestrator running; disable; refresh re-fetches. |
| 6.3.9 | Vision sub-agent reads attached images | RU | `src/openhuman/agent_registry/agents/loader.rs::vision_agent_loads_on_vision_hint`, `src/openhuman/inference/provider/factory_tests.rs::vision_tier_is_vision_capable`, `src/openhuman/agent/harness/engine/core.rs::gate_tests`, `src/openhuman/agent/multimodal_tests.rs::extract_image_placeholders_pulls_att_tokens_in_order` | ✅ | Orchestrator (non-vision `chat-v1`) keeps the image as a placeholder, delegates to `vision_agent` on the `vision-v1` tier, which rehydrates the on-disk attachment and reads it. Engine gate prefers per-tier `current_model_vision`; turn placeholders forwarded into the sub-agent prompt. |
| 6.3.10 | Auto-accept contact requests from linked agents | RU | `src/openhuman/agent_orchestration/pairing.rs::{auto_accept_gate_accepts_linked_but_leaves_others_pending,auto_accept_gate_unifies_base58_and_base64_of_same_key,auto_accept_gate_accepts_nothing_with_empty_linked_set,auto_accept_fails_closed_on_unreadable_pairing_store,incoming_pending_requesters_filters_and_resolves_requester}` | ✅ | On an inbound tiny.place `contact_request`, OpenHuman auto-accepts iff the requester is in `linked_agent_ids()` (its own paired agents) and otherwise leaves it pending for the human — the e2e gate that stops the relay dropping a linked agent's `session_info` intro. Fail-closed: a pairing-store read error yields an empty linked set → nothing auto-accepted. base58/base64 encodings of the same key unify via the shared DM-ingest matcher. |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ---------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. |
| 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. |
| 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. |
| 6.3.4 | Subconscious trigger pipeline (normalize → dedupe/rate → gate → queue) | RU+RI | `src/openhuman/subconscious_triggers/`, `tests/subconscious_triggers_e2e.rs` | ✅ | Event→Trigger normalization for cron/user/composio/sub-agent, dedupe TTL + per-source rate limit, LLM gate over `agent::triage`, priority queue with overflow eviction. |
| 6.3.5 | Long-lived subconscious orchestrator session | RU | `src/openhuman/subconscious/session.rs`, `src/openhuman/subconscious/user_thread.rs` | ✅ | Persistent compressed session backed by a reserved thread; `notify_user` handoff to the user-facing thread; mode→autonomy config parity. |
| 6.3.6 | Multi-party human↔subconscious↔sub-agent conversation | RI | `tests/subconscious_conversation_e2e.rs` | ✅ | Scripted Gate/SessionExecutor seam drives delegate→sub-agent→merge, failure/retry, interleaving, dedupe, and rate-limit scenarios through the real orchestrator. |
| 6.3.7 | Full-stack trigger pipeline with mocked LLM | RI | `tests/subconscious_fullstack_e2e.rs` (feature `e2e-test-support`) | ✅ | Real `GatePass`+`LongLivedSession`+`Agent`+sub-agent run against a provider-layer mock (no network); promote/drop, persistence, real `spawn_subagent`. |
| 6.3.8 | Subconscious Triggers debug/manage panel (Brain) | WD | `app/test/playwright/specs/subconscious-triggers.spec.ts` | ✅ | Brain→Subconscious panel: renders disabled baseline + hint + reserved thread ids; enable toggle → Pipeline Enabled + event_driven + orchestrator running; disable; refresh re-fetches. |
| 6.3.9 | Vision sub-agent reads attached images | RU | `src/openhuman/agent_registry/agents/loader.rs::vision_agent_loads_on_vision_hint`, `src/openhuman/inference/provider/factory_tests.rs::vision_tier_is_vision_capable`, `src/openhuman/agent/harness/engine/core.rs::gate_tests`, `src/openhuman/agent/multimodal_tests.rs::extract_image_placeholders_pulls_att_tokens_in_order` | ✅ | Orchestrator (non-vision `chat-v1`) keeps the image as a placeholder, delegates to `vision_agent` on the `vision-v1` tier, which rehydrates the on-disk attachment and reads it. Engine gate prefers per-tier `current_model_vision`; turn placeholders forwarded into the sub-agent prompt. |
| 6.3.10 | Auto-accept contact requests from linked agents | RU | `src/openhuman/agent_orchestration/pairing.rs::{auto_accept_gate_accepts_linked_but_leaves_others_pending,auto_accept_gate_unifies_base58_and_base64_of_same_key,auto_accept_gate_accepts_nothing_with_empty_linked_set,auto_accept_fails_closed_on_unreadable_pairing_store,incoming_pending_requesters_filters_and_resolves_requester}` | ✅ | On an inbound tiny.place `contact_request`, OpenHuman auto-accepts iff the requester is in `linked_agent_ids()` (its own paired agents) and otherwise leaves it pending for the human — the e2e gate that stops the relay dropping a linked agent's `session_info` intro. Fail-closed: a pairing-store read error yields an empty linked set → nothing auto-accepted. base58/base64 encodings of the same key unify via the shared DM-ingest matcher. |
### 6.4 Managed Cloud File Storage
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ---------------------------------------------------------------- | ----- | ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 6.4.1 | `storage_upload_file` (multipart, quota/TTL args, path safety) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | wiremock upload against the backend envelope; rejects workspace-escaping/symlinked paths, bad visibility/ttl; surfaces backend errors (e.g. insufficient balance). |
| 6.4.2 | `storage_download_file` (redirect follow + persist to workspace) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Follows the 302 to the presigned URL, persists bytes under the action dir, honors explicit filename. |
| 6.4.3 | `storage_list_files` / `storage_get_link` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | List + usage rendering; presigned link generation with expiry arg validation. |
| 6.4.4 | `storage_set_visibility` / `storage_delete_file` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Public/private toggle surfaces the stable public URL; delete confirms backend `deleted` flag; both are Write-level tools with external effect. |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ---------------------------------------------------------------- | ----- | ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 6.4.1 | `storage_upload_file` (multipart, quota/TTL args, path safety) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | wiremock upload against the backend envelope; rejects workspace-escaping/symlinked paths, bad visibility/ttl; surfaces backend errors (e.g. insufficient balance). |
| 6.4.2 | `storage_download_file` (redirect follow + persist to workspace) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Follows the 302 to the presigned URL, persists bytes under the action dir, honors explicit filename. |
| 6.4.3 | `storage_list_files` / `storage_get_link` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | List + usage rendering; presigned link generation with expiry arg validation. |
| 6.4.4 | `storage_set_visibility` / `storage_delete_file` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Public/private toggle surfaces the stable public URL; delete confirms backend `deleted` flag; both are Write-level tools with external effect. |
---
@@ -335,12 +335,13 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 8.2 Memory Handling
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- |
| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | |
| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` |
| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted |
| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | |
| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` |
| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted |
| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC |
| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `src/openhuman/tinycortex/persona.rs`, `tests/coding_sessions_feature.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources |
### 8.3 Memory Retrieval Benchmarks
@@ -409,13 +410,13 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 10.1 Integration Setup
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | |
| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ |
| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | |
| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ |
| 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | |
| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ |
| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | |
| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ |
| 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. |
| 10.1.6 | Email (IMAP/SMTP) Connection | RU+VU | `src/openhuman/channels/controllers/definitions_tests.rs::email_*`, `src/openhuman/channels/controllers/ops/connect.rs::email_config_tests`, `src/openhuman/channels/controllers/ops_tests.rs::{persist_email_config_*,disconnect_email_*,connect_email_rejects_invalid_port_*,test_channel_email_rejects_invalid_port_*}`, `app/src/components/channels/CredentialChannelConfig.test.tsx`, `app/src/components/channels/ChannelConfigPanel.test.tsx` | 🟡 | #4280 — native IMAP/SMTP for non-Gmail/Outlook mailboxes surfacing the existing `EmailChannel`. RU covers credentials→`EmailConfig` mapping/defaults, port/sender parsing, definition/validation, config persist + disconnect, and pre-network invalid-port rejection. VU covers the connect form rendering/submit + panel routing. Live IMAP verify + WDIO connect-flow are follow-ups. |
### 10.2 Authentication & Authorization
@@ -428,12 +429,12 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 10.3 Message Sync & Ingestion
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | |
| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | |
| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | |
| 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/whatsapp_data/tools/`, `tests/json_rpc_e2e.rs::whatsapp_data_agent_tools_e2e_1341` | ✅ | Three read-only agent tools wrap the local SQLite store; ingest stays internal-only. See [`src/openhuman/whatsapp_data/README.md`](../src/openhuman/whatsapp_data/README.md). |
| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | |
| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | |
### 10.4 Messaging Operations
@@ -489,7 +490,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
| 11.1.12 | MCP UI surface + setup-agent client | VU/RU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/components/channels/mcp/McpServersTab.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts`, `src/openhuman/mcp_registry/{curation,registry,registries/mcp_official}.rs` (#3039, #4272) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper; curated "perfect server" catalog (declared website + named credential) with official-vendor badge + official-first order; namespace-stripped relevance search + server-side Stdio/Hosted transport filter; clickable Website/Repo links; wired connection health toolbar (Retry all / Disconnect all) (#4272) |
| 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend |
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
| 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) |
<!-- 11.1.9 Vault Markdown Writes — removed: Knowledge Vaults dropped (vault domain + VaultPanel deleted). -->
@@ -504,10 +505,10 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 11.3 Hosted Orchestration
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ---------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | --------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 11.3.1 | Hosted-only orchestration (client = trigger + effects + render) | RI+VU | `tests/orchestration_hosted_client.rs`, `app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx`, `app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx` | ✅ | Local wake-graph brain retired (frontend_agent/graph/master_agent/reasoning_agent deleted). Client forwards events to the hosted brain (`POST /orchestration/v1/events`), uploads world-diffs, syncs hosted sessions/messages/steering into the render cache, and executes `send_dm`/`evict` socket effects; cloud-unreachable banner on outage. |
| 11.3.2 | Direct paid Medulla orchestration with local OpenHuman tools | RU | `src/openhuman/orchestration/medulla.rs`, `src/openhuman/orchestration/schemas.rs` | ✅ | `openhuman.orchestration_run` checks the active paid plan, starts a hosted Medulla cycle, executes requested contact/session/send tools locally, and continues pending/tool-use events to a final result. Mocked HTTP tests cover direct and tool-loop success plus plan, pending, backend-error, unknown-tool, and tool-failure paths. |
| 11.3.2 | Direct paid Medulla orchestration with local OpenHuman tools | RU | `src/openhuman/orchestration/medulla.rs`, `src/openhuman/orchestration/schemas.rs` | ✅ | `openhuman.orchestration_run` checks the active paid plan, starts a hosted Medulla cycle, executes requested contact/session/send tools locally, and continues pending/tool-use events to a final result. Mocked HTTP tests cover direct and tool-loop success plus plan, pending, backend-error, unknown-tool, and tool-failure paths. |
---
+16
View File
@@ -14,6 +14,12 @@ const DERIVED_TO_BACKEND: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
destinations: &["OpenHuman backend", "TinyHumans Neocortex"],
});
const CODING_SESSION_TO_BACKEND: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
leaves_device: true,
data_kind: PrivacyDataKind::Raw,
destinations: &["Configured OpenHuman inference provider"],
});
// Vision sub-agent ships the attached image (raw pixels) to the managed
// multimodal model for analysis.
const IMAGE_TO_BACKEND: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
@@ -507,6 +513,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.coding_session_memory",
name: "Coding-Agent Session Memory",
domain: "memory_sources",
category: CapabilityCategory::Intelligence,
description: "Discover local Codex and Claude Code session histories, retain only human-authored decisions and corrections, and distill them into a durable TinyCortex persona memory pack. Tool output, reasoning, developer prompts, and subagent traffic are excluded before inference.",
how_to: "Brain > Sources > Coding-agent sessions > Ingest new sessions. Programmatic: openhuman.memory_sources_coding_session_status and openhuman.memory_sources_ingest_coding_sessions (RPC).",
status: CapabilityStatus::Beta,
privacy: CODING_SESSION_TO_BACKEND,
},
Capability {
id: "intelligence.memory_sync_schedule",
name: "Memory Sync Schedule",
+17
View File
@@ -158,6 +158,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
"intelligence.embedding_provider_test",
"intelligence.github_repo_memory_source",
"intelligence.memory_source_sync_controls",
"intelligence.coding_session_memory",
"conversation.subagent_mascots",
] {
assert!(
@@ -167,6 +168,22 @@ fn catalog_includes_additional_user_facing_surfaces() {
}
}
#[test]
fn coding_session_memory_discloses_inference_boundary() {
let capability = lookup("intelligence.coding_session_memory")
.expect("coding-session memory capability registered");
assert_eq!(capability.domain, "memory_sources");
assert!(capability.description.contains("Codex"));
assert!(capability.description.contains("Claude Code"));
let privacy = capability.privacy.expect("privacy disclosure");
assert!(privacy.leaves_device);
assert_eq!(privacy.data_kind, PrivacyDataKind::Raw);
assert_eq!(
privacy.destinations,
&["Configured OpenHuman inference provider"]
);
}
/// The two embeddings entries surface a Settings-side configuration panel.
/// They share the same domain (`embeddings`) but are listed under the
/// Intelligence umbrella so they sit next to memory_tree_retrieval / mcp_server
@@ -118,6 +118,9 @@ impl ArchivistHook {
tree_kind: TreeKind::Source,
target_level: 0,
token_budget: 2_000,
input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET,
overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS,
ask: None,
};
let first = entries.first().map(|e| e.content.as_str()).unwrap_or("");
@@ -131,13 +131,11 @@ impl Agent {
};
let mut streamed_text = String::new();
let mut streamed_deltas = Vec::new();
let mut completed = None;
while let Some(item) = stream.next().await {
match item {
ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => {
streamed_text.push_str(&delta.text);
streamed_deltas.push(delta.text);
}
ModelStreamItem::Completed(response) => completed = Some(response),
ModelStreamItem::Failed(error) => {
@@ -157,52 +155,84 @@ impl Agent {
};
let usage = crate::openhuman::tinyagents::model::usage_info_from_response(&response);
let text = response.text();
let checkpoint = if !text.trim().is_empty() {
text
} else if response.tool_calls().is_empty() {
streamed_text.clone()
} else {
String::new()
// Tools are disabled for wrap-up calls, but text-protocol models can
// still ignore that instruction. Parse through the active dispatcher
// so XML/JSON and registry-backed P-Format calls are all rejected. The
// completed response and buffered deltas are checked independently:
// some providers only preserve one of those representations.
let parsed_call_count = |candidate: &str| {
self.tool_dispatcher
.parse_response(&ChatResponse {
text: Some(candidate.to_string()),
..ChatResponse::default()
})
.1
.len()
};
if !checkpoint.trim().is_empty() {
let mut provider_response = ChatResponse {
text: Some(checkpoint.clone()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
};
let (_, mut prompt_tool_calls) =
self.tool_dispatcher.parse_response(&provider_response);
if streamed_text != checkpoint {
provider_response.text = Some(streamed_text);
let (_, streamed_tool_calls) =
self.tool_dispatcher.parse_response(&provider_response);
prompt_tool_calls.extend(streamed_tool_calls);
}
if !prompt_tool_calls.is_empty() {
tracing::warn!(
parsed_tool_calls = prompt_tool_calls.len(),
"[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback"
);
return (String::new(), usage);
}
tracing::debug!(
checkpoint_chars = checkpoint.chars().count(),
buffered_deltas = streamed_deltas.len(),
let parsed_response_calls = parsed_call_count(&text);
let parsed_stream_calls = if streamed_text == text {
parsed_response_calls
} else {
parsed_call_count(&streamed_text)
};
let native_tool_calls = response.tool_calls().len();
let attempted_tool_call =
native_tool_calls > 0 || parsed_response_calls > 0 || parsed_stream_calls > 0;
let checkpoint = if attempted_tool_call {
tracing::warn!(
model = effective_model,
iteration = iteration_for_stream,
"[agent::session] wrap-up checkpoint validation passed"
native_tool_calls,
parsed_response_calls,
parsed_stream_calls,
"[agent::session] wrap-up attempted a tool call; using deterministic fallback"
);
String::new()
} else if !text.trim().is_empty() {
tracing::debug!(
model = effective_model,
iteration = iteration_for_stream,
text_len = text.len(),
"[agent::session] wrap-up selected completed response text"
);
text
} else {
tracing::debug!(
model = effective_model,
iteration = iteration_for_stream,
text_len = streamed_text.len(),
"[agent::session] wrap-up selected buffered stream text"
);
streamed_text
};
// Hold wrap-up deltas until protocol validation completes. Otherwise a
// rejected XML/P-Format tool call briefly renders in chat even though
// the caller subsequently replaces it with a deterministic fallback.
if !checkpoint.is_empty() {
if let Some(sink) = &self.on_progress {
for delta in streamed_deltas {
let _ = sink
.send(AgentProgress::TextDelta {
delta,
iteration: iteration_for_stream,
})
.await;
if let Err(error) = sink
.send(AgentProgress::TextDelta {
delta: checkpoint.clone(),
iteration: iteration_for_stream,
})
.await
{
tracing::debug!(
model = effective_model,
iteration = iteration_for_stream,
error = %error,
"[agent::session] wrap-up progress sink closed"
);
}
}
}
tracing::debug!(
model = effective_model,
iteration = iteration_for_stream,
checkpoint_len = checkpoint.len(),
used_deterministic_fallback = attempted_tool_call,
"[agent::session] wrap-up checkpoint selection complete"
);
(checkpoint, usage)
}
@@ -1,5 +1,7 @@
use super::*;
use crate::openhuman::agent::dispatcher::XmlToolDispatcher;
use crate::openhuman::agent::dispatcher::{
PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher,
};
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
use crate::openhuman::agent::tool_policy::{
GeneratedToolRuntimeContext, GeneratedToolRuntimeRisk, ToolPolicy, ToolPolicyDecision,
@@ -340,6 +342,26 @@ fn make_agent_with_builder(
post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
config: crate::openhuman::config::AgentConfig,
context_config: crate::openhuman::config::ContextConfig,
) -> Agent {
make_agent_with_builder_and_dispatcher(
provider,
tools,
memory_loader,
post_turn_hooks,
config,
context_config,
Box::new(XmlToolDispatcher),
)
}
fn make_agent_with_builder_and_dispatcher(
provider: Arc<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
memory_loader: Box<dyn MemoryLoader>,
post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
config: crate::openhuman::config::AgentConfig,
context_config: crate::openhuman::config::ContextConfig,
tool_dispatcher: Box<dyn ToolDispatcher>,
) -> Agent {
let workspace = tempfile::TempDir::new().expect("temp workspace");
let workspace_path = workspace.path().to_path_buf();
@@ -357,7 +379,7 @@ fn make_agent_with_builder(
.tools(tools)
.memory(mem)
.memory_loader(memory_loader)
.tool_dispatcher(Box::new(XmlToolDispatcher))
.tool_dispatcher(tool_dispatcher)
.post_turn_hooks(post_turn_hooks)
.config(config)
.context_config(context_config)
@@ -1036,6 +1058,63 @@ async fn turn_checkpoint_falls_back_to_deterministic_summary_when_model_summary_
);
}
#[tokio::test]
async fn turn_checkpoint_rejects_pformat_wrapup_without_streaming_it() {
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
responses: AsyncMutex::new(vec![
Ok(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
..ChatResponse::default()
}),
Ok(ChatResponse {
text: Some("<tool_call>echo[]</tool_call>".into()),
..ChatResponse::default()
}),
]),
requests: AsyncMutex::new(Vec::new()),
});
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let registry = crate::openhuman::agent::pformat::build_registry(&tools);
let mut agent = make_agent_with_builder_and_dispatcher(
provider,
tools,
Box::new(FixedMemoryLoader {
context: String::new(),
}),
vec![],
crate::openhuman::config::AgentConfig {
max_tool_iterations: 1,
..crate::openhuman::config::AgentConfig::default()
},
crate::openhuman::config::ContextConfig::default(),
Box::new(PFormatToolDispatcher::new(registry)),
);
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(16);
agent.set_on_progress(Some(progress_tx));
let reply = agent
.turn("hello")
.await
.expect("P-Format wrap-up call should use the deterministic checkpoint");
assert!(
reply.contains("tool-call limit"),
"P-Format wrap-up must be rejected, got: {reply}"
);
agent.set_on_progress(None);
let mut rendered_invalid_wrapup = false;
while let Ok(progress) = progress_rx.try_recv() {
if let crate::openhuman::agent::progress::AgentProgress::TextDelta { delta, .. } = progress
{
rendered_invalid_wrapup |= delta.contains("echo[]");
}
}
assert!(
!rendered_invalid_wrapup,
"rejected P-Format wrap-up must not be emitted to the progress sink"
);
}
#[tokio::test]
async fn turn_synthesizes_final_answer_when_tool_turn_yields_no_text() {
// #4093: the model runs a tool and then yields a terminating response with
+7
View File
@@ -1017,6 +1017,10 @@ async fn composio_get_user_profile_via_mock_returns_provider_profile() {
}),
);
let base = start_mock_backend(app).await;
// ProviderContext reloads the saved config and applies runtime env
// overlays. Pin the backend override to the mock so CI's BACKEND_URL
// cannot redirect this request to the hosted API.
let _backend_url_guard = EnvVarGuard::set("BACKEND_URL", &base);
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let _workspace_env_guard = WorkspaceEnvGuard::set(tmp.path());
@@ -1168,6 +1172,9 @@ async fn composio_sync_gmail_via_mock_stores_skill_document_and_updates_outcome(
}),
);
let base = start_mock_backend(app).await;
// The provider action reloads config with env overlays before executing.
// Keep that reload on the mock even when the runner exports BACKEND_URL.
let _backend_url_guard = EnvVarGuard::set("BACKEND_URL", &base);
let tmp = tempfile::tempdir().unwrap();
let mut config = config_with_backend(&tmp, base);
config.memory_tree.embedding_strict = false;
+2
View File
@@ -881,10 +881,12 @@ async fn list_tools_in_direct_mode_returns_empty_without_hitting_backend() {
let tmp = tempfile::tempdir().expect("tempdir");
let _workspace_guard = WorkspaceEnvGuard::set(tmp.path());
let _home_guard = HomeEnvGuard::set(tmp.path());
let mut config = crate::openhuman::config::Config::default();
config.config_path = tmp.path().join("config.toml");
config.workspace_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&config.workspace_dir).expect("create workspace dir");
config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_DIRECT.to_string();
config.composio.api_key = Some("test-direct-key".to_string());
config.save().await.expect("save fake config to disk");
+1 -1
View File
@@ -2851,7 +2851,7 @@ fn seeded_slack_send_message_contract_with_schema() -> ToolContract {
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
is_curated: false,
}
}
+37 -1
View File
@@ -80,7 +80,16 @@ impl Tool for MemoryStoreTool {
Some("core") | None => MemoryCategory::Core,
Some("daily") => MemoryCategory::Daily,
Some("conversation") => MemoryCategory::Conversation,
Some(other) => MemoryCategory::Custom(other.to_string()),
// Route custom categories through `FromStr` so a `custom:<name>`
// wire value — the form `memory_recall`/`Display` now emit — resolves
// back to `Custom("<name>")` instead of `Custom("custom:<name>")`
// (which would `Display` as `custom:custom:<name>` and stop matching
// the original category on recall/filter). Legacy bare names still
// parse to the same `Custom(name)`; an unparseable value falls back
// to the raw string. (review: prefixed-custom round-trip)
Some(other) => other
.parse()
.unwrap_or_else(|_| MemoryCategory::Custom(other.to_string())),
};
if let Err(error) = self
@@ -204,6 +213,33 @@ mod tests {
assert_eq!(entry.category, MemoryCategory::Custom("project".into()));
}
/// Regression: a `custom:<name>` wire value (the form `memory_recall` and
/// `Display` now emit) must store as `Custom("<name>")`, not the
/// double-prefixed `Custom("custom:<name>")` — otherwise it would `Display`
/// as `custom:custom:<name>` and stop matching the original category.
#[tokio::test]
async fn store_strips_custom_prefix_from_wire_category() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem.clone(), test_security());
let result = tool
.execute(json!({
"namespace": "global",
"key": "proj_note",
"content": "Uses async runtime",
"category": "custom:project"
}))
.await
.unwrap();
assert!(!result.is_error);
let entry = mem.get("global", "proj_note").await.unwrap().unwrap();
assert_eq!(
entry.category,
MemoryCategory::Custom("project".into()),
"the `custom:` wire prefix must be stripped, not double-stored"
);
}
#[tokio::test]
async fn store_rejects_secret_like_content() {
let (_tmp, mem) = test_mem();
+17 -1
View File
@@ -36,9 +36,25 @@ mod tests {
assert_eq!(MemoryCategory::Core.to_string(), "core");
assert_eq!(MemoryCategory::Daily.to_string(), "daily");
assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
// TinyCortex renders `Custom(name)` with a `custom:` prefix so it stays
// distinct from the built-in variants and `Display`/`FromStr` are true
// inverses (see `memory_category_from_stored`).
assert_eq!(
MemoryCategory::Custom("project_notes".into()).to_string(),
"project_notes"
"custom:project_notes"
);
}
#[test]
fn memory_category_custom_wire_values_round_trip_and_accept_legacy_bare_values() {
let current: MemoryCategory = "custom:project_notes".parse().unwrap();
let legacy: MemoryCategory = "project_notes".parse().unwrap();
assert_eq!(current, MemoryCategory::Custom("project_notes".into()));
assert_eq!(legacy, MemoryCategory::Custom("project_notes".into()));
assert_eq!(
serde_json::to_string(&current).unwrap(),
"\"custom:project_notes\""
);
}
+1
View File
@@ -150,6 +150,7 @@ mod tests {
id: "source:abc".into(),
kind: TreeKind::Source,
scope: scope.into(),
ask: None,
root_id: None,
max_level: 0,
status: TreeStatus::Active,
+26 -4
View File
@@ -1030,10 +1030,32 @@ mod tests {
.unwrap()
.expect("enqueue backfill job");
let processed = run_once(&cfg).await.unwrap();
assert!(processed);
let job = get_job(&cfg, &id).unwrap().expect("job should still exist");
// The TinyCortex LLM gate is process-global, so a parallel libtest can
// briefly own its single permit. In that case `run_once` legitimately
// defers this row for 50 ms with `llm concurrency gate busy` before the
// re-embed handler is reached. Retry that transient gate deferral so
// this test continues to pin the handler's own defer/reschedule path.
let mut job = None;
for _ in 0..20 {
let processed = run_once(&cfg).await.unwrap();
assert!(processed);
let current = get_job(&cfg, &id).unwrap().expect("job should still exist");
if current
.last_error
.as_deref()
.is_some_and(|reason| reason.contains("re-embed backfill"))
{
job = Some(current);
break;
}
assert_eq!(
current.last_error.as_deref(),
Some("llm concurrency gate busy"),
"unexpected defer reason before re-embed handler"
);
tokio::time::sleep(Duration::from_millis(60)).await;
}
let job = job.expect("re-embed handler should run after transient gate contention");
assert_eq!(job.kind, JobKind::ReembedBackfill);
assert_eq!(job.status, JobStatus::Ready);
assert_eq!(
@@ -110,7 +110,16 @@ impl Tool for MemoryHybridSearchTool {
));
}
let profile = WeightProfile::by_name(&parsed.mode);
let profile = WeightProfile::by_name(&parsed.mode).ok_or_else(|| {
log::warn!(
"[tool][memory_hybrid_search] rejected unknown mode={}",
parsed.mode
);
anyhow::anyhow!(
"memory_hybrid_search: unknown mode '{}'; expected balanced, semantic, lexical, or graph_first",
parsed.mode
)
})?;
let limit = parsed.limit.clamp(1, 50);
log::debug!(
@@ -209,3 +218,26 @@ impl Tool for MemoryHybridSearchTool {
Ok(ToolResult::success(output))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn rejects_unknown_mode_before_opening_external_search_resources() {
let error = MemoryHybridSearchTool
.execute(json!({
"query": "release checklist",
"namespace": "global",
"mode": "mystery"
}))
.await
.expect_err("an unknown mode must fail validation");
let message = error.to_string();
assert!(message.contains("unknown mode 'mystery'"), "{message}");
// Validation runs before config, provider, and store setup. Reaching any
// external search path would replace this precise validation error.
assert!(!message.contains("load config failed"), "{message}");
}
}
@@ -152,6 +152,7 @@ impl Tool for MemoryVectorSearchTool {
since_ms,
until_ms: None,
limit: Some(1000),
offset: None,
source_scope: crate::openhuman::memory::source_scope::current_source_scope(),
exclude_dropped: false,
};
+76
View File
@@ -6,6 +6,82 @@ use crate::openhuman::memory_sources::registry::{self, MemorySourcePatch};
use crate::openhuman::memory_sources::types::{MemorySourceEntry, SourceKind};
use crate::rpc::RpcOutcome;
#[derive(Debug, serde::Serialize)]
pub struct CodingSessionStatusResponse {
pub sources: Vec<crate::openhuman::tinycortex::CodingSessionSourceStatus>,
}
pub async fn coding_session_status_rpc() -> Result<RpcOutcome<CodingSessionStatusResponse>, String>
{
tracing::debug!("[memory_sources] coding_session_status_rpc: entry");
let sources = tokio::task::spawn_blocking(crate::openhuman::tinycortex::coding_session_status)
.await
.map_err(|error| format!("join coding-session discovery: {error}"))?;
tracing::debug!(
sources = sources.len(),
files = sources
.iter()
.map(|source| source.session_files)
.sum::<usize>(),
"[memory_sources] coding_session_status_rpc: exit"
);
Ok(RpcOutcome::new(
CodingSessionStatusResponse { sources },
vec![],
))
}
pub async fn ingest_coding_sessions_rpc(
req: crate::openhuman::tinycortex::CodingSessionIngestRequest,
) -> Result<RpcOutcome<crate::openhuman::tinycortex::CodingSessionIngestResponse>, String> {
tracing::info!("[memory_sources] ingest_coding_sessions_rpc: entry");
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|error| format!("load config for coding-session ingestion: {error}"))?;
// TinyCortex's persona pipeline intentionally carries borrowed path state
// and is not `Send`. Drive it from a blocking worker while its async I/O
// remains attached to the ambient Tokio runtime, keeping the controller
// future itself Send-safe for the registry.
let runtime = tokio::runtime::Handle::current();
// Wall-clock ceiling so a stalled provider call or a wedged session step
// can't keep the RPC (and its blocking worker) waiting indefinitely (#4863
// review). Scale to the requested budget — each session drives at most one
// LLM call — so a large backfill isn't killed mid-flight while a genuine
// infinite hang still terminates. `max_sessions` is untrusted, so cap the
// multiplier before computing the budget.
let ingest_timeout =
std::time::Duration::from_secs(120 + (req.max_sessions.min(1_000) as u64) * 30);
let response = tokio::task::spawn_blocking(move || {
runtime.block_on(async move {
tokio::time::timeout(
ingest_timeout,
crate::openhuman::tinycortex::ingest_coding_sessions(&config, req),
)
.await
})
})
.await
.map_err(|error| format!("join coding-session ingestion: {error}"))?
.map_err(|_elapsed| {
tracing::error!(
timeout_secs = ingest_timeout.as_secs(),
"[memory_sources] ingest_coding_sessions_rpc: timed out"
);
format!(
"ingest coding sessions: timed out after {}s",
ingest_timeout.as_secs()
)
})?
.map_err(|error| format!("ingest coding sessions: {error:#}"))?;
tracing::info!(
processed = response.sessions_processed,
failed = response.sessions_failed,
budget_hit = response.budget_hit,
"[memory_sources] ingest_coding_sessions_rpc: exit"
);
Ok(RpcOutcome::new(response, vec![]))
}
// ── List ──
#[derive(Debug, serde::Serialize)]
+65
View File
@@ -135,6 +135,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("estimate_sync_cost"),
schemas("monthly_cost_summary"),
schemas("apply_all_in"),
schemas("coding_session_status"),
schemas("ingest_coding_sessions"),
]
}
@@ -200,6 +202,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("apply_all_in"),
handler: handle_apply_all_in,
},
RegisteredController {
schema: schemas("coding_session_status"),
handler: handle_coding_session_status,
},
RegisteredController {
schema: schemas("ingest_coding_sessions"),
handler: handle_ingest_coding_sessions,
},
]
}
@@ -586,6 +596,48 @@ pub fn schemas(function: &str) -> ControllerSchema {
},
],
},
"coding_session_status" => ControllerSchema {
namespace: NAMESPACE,
function: "coding_session_status",
description: "Discover local Codex and Claude Code session histories and report the human-authored evidence available for memory ingestion.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "sources",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("CodingSessionSourceStatus"))),
comment: "Discovery and evidence counts for each supported coding-agent session source.",
required: true,
}],
},
"ingest_coding_sessions" => ControllerSchema {
namespace: NAMESPACE,
function: "ingest_coding_sessions",
description: "Distill human-authored turns from local Codex and Claude Code sessions into the TinyCortex persona memory layer.",
inputs: vec![
FieldSchema {
name: "backfill",
ty: TypeSchema::Bool,
comment: "When true, reprocess all discovered sessions; otherwise ingest only changed sessions.",
required: false,
},
FieldSchema {
name: "max_sessions",
ty: TypeSchema::U64,
comment: "Maximum session digests for this run (clamped to 1,000).",
required: false,
},
],
outputs: vec![
FieldSchema { name: "mode", ty: TypeSchema::String, comment: "Executed run mode.", required: true },
FieldSchema { name: "files_seen", ty: TypeSchema::U64, comment: "Discovered coding-session files.", required: true },
FieldSchema { name: "sessions_processed", ty: TypeSchema::U64, comment: "Coding sessions distilled successfully.", required: true },
FieldSchema { name: "sessions_skipped", ty: TypeSchema::U64, comment: "Unchanged sessions skipped during an incremental run.", required: true },
FieldSchema { name: "sessions_failed", ty: TypeSchema::U64, comment: "Sessions retained for retry after provider failure.", required: true },
FieldSchema { name: "evidence_units", ty: TypeSchema::U64, comment: "Human-authored evidence units extracted.", required: true },
FieldSchema { name: "observations", ty: TypeSchema::U64, comment: "Persona observations distilled.", required: true },
FieldSchema { name: "budget_hit", ty: TypeSchema::Bool, comment: "Whether the run stopped at its session/call budget.", required: true },
FieldSchema { name: "pack_path", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Compiled persona pack path when written.", required: false },
],
},
other => panic!("unknown memory_sources schema function: {other}"),
}
}
@@ -677,6 +729,19 @@ fn handle_apply_all_in(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(rpc::apply_all_in_rpc().await?) })
}
fn handle_coding_session_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(rpc::coding_session_status_rpc().await?) })
}
fn handle_ingest_coding_sessions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<crate::openhuman::tinycortex::CodingSessionIngestRequest>(
Value::Object(params),
)?;
to_json(rpc::ingest_coding_sessions_rpc(req).await?)
})
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
+17 -6
View File
@@ -45,13 +45,24 @@ fn normalize_namespace(namespace: Option<&str>) -> &str {
}
/// Helper to convert a raw string category from the database into a `MemoryCategory`.
///
/// The store persists a category via its `Display` form, and the current
/// TinyCortex format renders `Custom(name)` as `custom:{name}` (so `Custom("core")`
/// stays distinct from `Core`). Parse back through `FromStr` — the true inverse of
/// `Display` — so the `custom:` prefix is stripped symmetrically. Wrapping the raw
/// string in `Custom(_)` instead (the previous behaviour) double-prefixed on
/// read-back once the wire format gained the prefix. An empty stored value has no
/// `FromStr` mapping, so it falls back to an empty `Custom` (matching the prior
/// catch-all for that degenerate case).
fn memory_category_from_stored(raw: &str) -> MemoryCategory {
match raw {
"core" => MemoryCategory::Core,
"daily" => MemoryCategory::Daily,
"conversation" => MemoryCategory::Conversation,
other => MemoryCategory::Custom(other.to_string()),
}
raw.parse().unwrap_or_else(|error| {
tracing::debug!(
category_chars = raw.chars().count(),
reason = %error,
"[memory_store] invalid stored category; preserving as custom"
);
MemoryCategory::Custom(raw.to_string())
})
}
#[async_trait]
@@ -128,6 +128,7 @@ impl RetrievalFacade {
since_ms: filters.since_ms,
until_ms: filters.until_ms,
limit: filters.limit,
offset: None,
source_scope: None,
exclude_dropped: false,
};
@@ -101,6 +101,7 @@ impl Tool for MemoryStoreRawChunksTool {
since_ms: parsed.since_ms,
until_ms: parsed.until_ms,
limit: parsed.limit,
offset: None,
source_scope: crate::openhuman::memory::source_scope::current_source_scope(),
exclude_dropped: false,
};
+1
View File
@@ -252,6 +252,7 @@ mod tests {
id: "tree-1".into(),
kind: crate::openhuman::memory_store::trees::TreeKind::Topic,
scope: "topic:phoenix".into(),
ask: None,
root_id: Some("summary-root".into()),
max_level: 2,
status: crate::openhuman::memory_store::trees::TreeStatus::Active,
@@ -16,6 +16,7 @@ fn sample_tree(id: &str, scope: &str) -> Tree {
id: id.to_string(),
kind: TreeKind::Source,
scope: scope.to_string(),
ask: None,
root_id: None,
max_level: 0,
status: TreeStatus::Active,
@@ -35,6 +35,7 @@ pub fn get_or_create_tree(config: &Config, kind: TreeKind, scope: &str) -> Resul
id: new_tree_id(kind),
kind,
scope: scope.to_string(),
ask: None,
root_id: None,
max_level: 0,
status: TreeStatus::Active,
@@ -201,6 +202,7 @@ mod tests {
id: "source:preexisting".into(),
kind: TreeKind::Source,
scope: "slack:#eng".into(),
ask: None,
root_id: None,
max_level: 0,
status: TreeStatus::Active,
+1
View File
@@ -139,6 +139,7 @@ pub async fn list_chunks_rpc(
since_ms: req.since_ms,
until_ms: req.until_ms,
limit: req.limit,
offset: None,
source_scope: None,
exclude_dropped: false,
};
+33 -14
View File
@@ -1,29 +1,48 @@
//! Host adapters for tinycortex on-demand ingestion.
use tinycortex::memory::ingest::TreeJobSink;
use rusqlite::Transaction;
use tinycortex::memory::ingest::{QueueJobSink, TreeJobSink};
use tinycortex::memory::score::extract::{LlmEntityExtractor, LlmExtractorConfig};
use tinycortex::memory::score::ScoringConfig;
use crate::openhuman::config::Config;
use crate::openhuman::memory_queue::{self, ExtractChunkPayload, NewJob};
pub struct HostTreeJobSink {
config: Config,
}
#[derive(Default)]
pub struct HostTreeJobSink;
impl HostTreeJobSink {
pub fn new(config: Config) -> Self {
Self { config }
pub fn new() -> Self {
Self
}
}
impl TreeJobSink for HostTreeJobSink {
fn enqueue_extract(&self, chunk_id: &str) -> anyhow::Result<()> {
let job = NewJob::extract_chunk(&ExtractChunkPayload {
chunk_id: chunk_id.into(),
})?;
memory_queue::enqueue(&self.config, &job)?;
Ok(())
fn enqueue_extract_tx(
&self,
tx: &Transaction<'_>,
chunk_id: &str,
default_max_attempts: u32,
) -> anyhow::Result<bool> {
tracing::trace!(
chunk_id,
default_max_attempts,
"[memory:ingest] enqueue extract job in chunk transaction"
);
let enqueued = QueueJobSink
.enqueue_extract_tx(tx, chunk_id, default_max_attempts)
.inspect_err(|error| {
tracing::error!(
chunk_id,
error = %error,
"[memory:ingest] enqueue extract job failed"
);
})?;
tracing::trace!(
chunk_id,
enqueued,
"[memory:ingest] enqueue extract job outcome (false = already queued)"
);
Ok(enqueued)
}
}
@@ -52,7 +71,7 @@ pub fn context(
) {
(
super::memory_config_from(config, config.workspace_dir.clone()),
HostTreeJobSink::new(config.clone()),
HostTreeJobSink::new(),
scoring_config(config),
)
}
+5
View File
@@ -39,6 +39,7 @@ mod embeddings;
mod ingest;
#[cfg(test)]
mod parity;
mod persona;
mod queue_driver;
mod seal;
mod summariser;
@@ -48,6 +49,10 @@ pub use chat::{build_chat_provider, SeamChatProvider};
pub use config::memory_config_from;
pub use embeddings::SeamEmbedder;
pub use ingest::{context as ingest_context, HostTreeJobSink};
pub use persona::{
coding_session_status, coding_session_status_for_roots, ingest_coding_sessions,
CodingSessionIngestRequest, CodingSessionIngestResponse, CodingSessionSourceStatus,
};
pub use queue_driver::{
classify_worker_error, HostQueueDelegates, WorkerErrorAction, WorkerReport,
};
+1 -1
View File
@@ -69,7 +69,7 @@ mod tests {
assert_eq!(bytes.len(), v.len() * 4);
assert_eq!(hex(&bytes), "0000803f000000c00000003f");
// Round-trips exactly.
assert_eq!(bytes_to_vec(&bytes), v);
assert_eq!(bytes_to_vec(&bytes).expect("valid packed f32 bytes"), v);
}
/// P6 — vault paths sanitize IDs to cross-platform-safe filenames. Chunk IDs
+446
View File
@@ -0,0 +1,446 @@
//! Host orchestration for TinyCortex coding-session persona ingestion.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tinycortex::memory::persona::readers::{claude_code, codex, RawSession};
use tinycortex::memory::persona::state::FileStateStore;
use tinycortex::memory::persona::{PersonaConfig, Pipeline, RunMode};
use walkdir::WalkDir;
use crate::openhuman::config::Config;
const DEFAULT_MAX_SESSIONS: usize = 100;
const MAX_MAX_SESSIONS: usize = 1_000;
const MAX_STATUS_SESSION_FILES: usize = 1_000;
const MAX_STATUS_SESSION_FILE_BYTES: u64 = 4 * 1024 * 1024;
const MAX_STATUS_TOTAL_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CodingSessionSourceStatus {
pub kind: String,
pub available: bool,
pub session_files: usize,
pub evidence_units: usize,
pub invalid_files: usize,
pub scan_truncated: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CodingSessionIngestRequest {
#[serde(default)]
pub backfill: bool,
#[serde(default = "default_max_sessions")]
pub max_sessions: usize,
}
fn default_max_sessions() -> usize {
DEFAULT_MAX_SESSIONS
}
#[derive(Debug, Clone, Serialize)]
pub struct CodingSessionIngestResponse {
pub mode: String,
pub files_seen: usize,
pub sessions_processed: usize,
pub sessions_skipped: usize,
pub sessions_failed: usize,
pub evidence_units: usize,
pub observations: usize,
pub budget_hit: bool,
pub pack_path: Option<String>,
}
fn roots_from_environment() -> (PathBuf, PathBuf) {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let claude_home = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".claude"));
let codex_home = std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".codex"));
(claude_home.join("projects"), codex_home.join("sessions"))
}
fn source_status(
kind: &str,
root: &Path,
max_files: usize,
discover: impl Fn(&Path, usize) -> (Vec<PathBuf>, bool),
read: impl Fn(&Path) -> anyhow::Result<RawSession>,
) -> CodingSessionSourceStatus {
let (files, mut scan_truncated) = discover(root, max_files);
if scan_truncated {
tracing::debug!(
source = kind,
max_files,
"[memory_persona] coding session status scan capped"
);
}
let mut evidence_units = 0;
let mut invalid_files = 0;
let mut bytes_scheduled = 0_u64;
for path in &files {
if let Ok(metadata) = path.metadata() {
let file_bytes = metadata.len();
if file_bytes > MAX_STATUS_SESSION_FILE_BYTES
|| bytes_scheduled.saturating_add(file_bytes) > MAX_STATUS_TOTAL_BYTES
{
scan_truncated = true;
tracing::debug!(
source = kind,
file_bytes,
bytes_scheduled,
max_file_bytes = MAX_STATUS_SESSION_FILE_BYTES,
max_total_bytes = MAX_STATUS_TOTAL_BYTES,
reason = "status-byte-budget",
"[memory_persona] skipped coding session during bounded status scan"
);
continue;
}
bytes_scheduled += file_bytes;
}
match read(path) {
Ok(session) => evidence_units += session.evidence.len(),
Err(_error) => {
invalid_files += 1;
tracing::debug!(
source = kind,
reason = "read-or-parse-failed",
"[memory_persona] skipped unreadable coding session"
);
}
}
}
CodingSessionSourceStatus {
kind: kind.to_string(),
available: root.is_dir(),
session_files: files.len(),
evidence_units,
invalid_files,
scan_truncated,
}
}
fn discover_session_files(
root: &Path,
max_files: usize,
is_candidate: impl Fn(&Path) -> bool,
) -> (Vec<PathBuf>, bool) {
let mut files = Vec::with_capacity(max_files.min(64));
// Keep traversal unsorted: `sort_by_file_name` buffers and sorts every
// directory before yielding its first entry, which defeats `max_files`
// for users with very large Codex day or Claude project directories.
for entry in WalkDir::new(root)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
{
let path = entry.path();
if !is_candidate(path) {
continue;
}
if files.len() == max_files {
return (files, true);
}
files.push(path.to_path_buf());
}
(files, false)
}
fn discover_claude_sessions(root: &Path, max_files: usize) -> (Vec<PathBuf>, bool) {
discover_session_files(root, max_files, |path| {
path.extension()
.is_some_and(|extension| extension == "jsonl")
})
}
fn discover_codex_sessions(root: &Path, max_files: usize) -> (Vec<PathBuf>, bool) {
discover_session_files(root, max_files, |path| {
path.extension()
.is_some_and(|extension| extension == "jsonl")
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("rollout-"))
})
}
pub fn coding_session_status_for_roots(
claude_root: &Path,
codex_root: &Path,
) -> Vec<CodingSessionSourceStatus> {
tracing::debug!("[memory_persona] coding session scan: entry");
let statuses = vec![
source_status(
"claude_code",
claude_root,
MAX_STATUS_SESSION_FILES,
discover_claude_sessions,
claude_code::read_session,
),
source_status(
"codex",
codex_root,
MAX_STATUS_SESSION_FILES,
discover_codex_sessions,
codex::read_session,
),
];
tracing::debug!(
files = statuses
.iter()
.map(|status| status.session_files)
.sum::<usize>(),
evidence = statuses
.iter()
.map(|status| status.evidence_units)
.sum::<usize>(),
invalid = statuses
.iter()
.map(|status| status.invalid_files)
.sum::<usize>(),
"[memory_persona] coding session scan: exit"
);
statuses
}
pub fn coding_session_status() -> Vec<CodingSessionSourceStatus> {
let (claude_root, codex_root) = roots_from_environment();
coding_session_status_for_roots(&claude_root, &codex_root)
}
pub async fn ingest_coding_sessions(
config: &Config,
request: CodingSessionIngestRequest,
) -> anyhow::Result<CodingSessionIngestResponse> {
let (claude_root, codex_root) = roots_from_environment();
let max_sessions = request.max_sessions.clamp(1, MAX_MAX_SESSIONS);
let mode = if request.backfill {
RunMode::Backfill
} else {
RunMode::Incremental
};
tracing::info!(
mode = if request.backfill {
"backfill"
} else {
"incremental"
},
max_sessions,
"[memory_persona] coding session ingestion: entry"
);
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
let mut persona = PersonaConfig::with_home(
dirs::home_dir()
.as_deref()
.unwrap_or_else(|| Path::new(".")),
"OpenHuman user",
);
persona.claude_code_root = Some(claude_root);
persona.codex_root = Some(codex_root);
// This product surface is deliberately scoped to coding-session history.
// Repository history and instruction files can be wired separately with
// their own disclosure and cost controls.
persona.project_roots.clear();
persona.global_instruction_files.clear();
persona.author_emails.clear();
persona.run_budget.max_sessions = max_sessions;
persona.run_budget.max_llm_calls = max_sessions as u32;
let provider = super::build_chat_provider(config).inspect_err(|error| {
tracing::error!(
error = %error,
"[memory_persona] coding session ingestion: build_chat_provider failed"
);
})?;
let summariser = super::HostSummariser::new(config.clone());
let store = FileStateStore::open_in_workspace(&config.workspace_dir).inspect_err(|error| {
tracing::error!(
error = %error,
"[memory_persona] coding session ingestion: open state store failed"
);
})?;
let report = Pipeline {
config: &memory_config,
persona: &persona,
provider: provider.as_ref(),
summariser: &summariser,
store: &store,
}
.run(mode)
.await
.inspect_err(|error| {
tracing::error!(
error = %error,
"[memory_persona] coding session ingestion: pipeline run failed"
);
})?;
tracing::info!(
files_seen = report.files_seen,
sessions_processed = report.sessions_processed,
sessions_failed = report.sessions_failed,
evidence_units = report.evidence_units,
observations = report.observations,
budget_hit = report.budget_hit,
"[memory_persona] coding session ingestion: exit"
);
Ok(CodingSessionIngestResponse {
mode: report.mode,
files_seen: report.files_seen,
sessions_processed: report.sessions_processed,
sessions_skipped: report.sessions_skipped,
sessions_failed: report.sessions_failed,
evidence_units: report.evidence_units,
observations: report.observations,
budget_hit: report.budget_hit,
pack_path: report.pack_path,
})
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn scans_codex_and_claude_sessions_and_filters_machine_content() {
let temp = tempdir().unwrap();
let claude = temp.path().join("claude");
let codex = temp.path().join("codex/2026/07/14");
fs::create_dir_all(&claude).unwrap();
fs::create_dir_all(&codex).unwrap();
fs::write(
claude.join("session.jsonl"),
concat!(
"{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"machine\"}]}}\n",
"{\"type\":\"user\",\"sessionId\":\"c1\",\"cwd\":\"/repo\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"message\":{\"content\":\"Prefer small modules\"}}\n"
),
)
.unwrap();
fs::write(
codex.join("rollout-test.jsonl"),
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"x1\",\"cwd\":\"/repo\"}}\n",
"{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"secret scaffolding\"}]}}\n",
"{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Run focused tests first\"}]}}\n"
),
)
.unwrap();
let statuses = coding_session_status_for_roots(&claude, &temp.path().join("codex"));
assert_eq!(statuses.len(), 2);
assert_eq!(statuses[0].session_files, 1);
assert_eq!(statuses[0].evidence_units, 1);
assert_eq!(statuses[1].session_files, 1);
assert_eq!(statuses[1].evidence_units, 1);
assert_eq!(statuses[0].invalid_files + statuses[1].invalid_files, 0);
}
#[test]
fn status_scan_stops_parsing_at_the_configured_limit() {
let paths = vec![PathBuf::from("one"), PathBuf::from("two")];
let reads = std::cell::Cell::new(0);
let status = source_status(
"fixture",
Path::new("."),
1,
|_, max_files| (paths[..max_files].to_vec(), paths.len() > max_files),
|_| {
reads.set(reads.get() + 1);
Ok(RawSession::new(
tinycortex::memory::persona::types::EvidenceSource::new(
tinycortex::memory::persona::types::PersonaSourceKind::Codex,
),
))
},
);
assert_eq!(reads.get(), 1);
assert_eq!(status.session_files, 1);
assert!(status.scan_truncated);
}
#[test]
fn bounded_discovery_stops_after_finding_one_extra_candidate_without_ordering() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("a.jsonl"), "").unwrap();
fs::write(temp.path().join("b.jsonl"), "").unwrap();
fs::write(temp.path().join("ignored.txt"), "").unwrap();
let (files, truncated) = discover_claude_sessions(temp.path(), 1);
assert_eq!(files.len(), 1);
assert_eq!(files[0].extension().unwrap(), "jsonl");
assert!(truncated);
}
#[test]
fn status_scan_skips_oversized_sessions_without_parsing_them() {
let temp = tempdir().unwrap();
let oversized = temp.path().join("oversized.jsonl");
let small = temp.path().join("small.jsonl");
let file = fs::File::create(&oversized).unwrap();
file.set_len(MAX_STATUS_SESSION_FILE_BYTES + 1).unwrap();
fs::write(&small, "{}\n").unwrap();
let reads = std::cell::Cell::new(0);
let status = source_status(
"fixture",
temp.path(),
2,
|_, _| (vec![oversized.clone(), small.clone()], false),
|_| {
reads.set(reads.get() + 1);
Ok(RawSession::new(
tinycortex::memory::persona::types::EvidenceSource::new(
tinycortex::memory::persona::types::PersonaSourceKind::Codex,
),
))
},
);
assert_eq!(reads.get(), 1);
assert_eq!(status.session_files, 2);
assert_eq!(status.invalid_files, 0);
assert!(status.scan_truncated);
}
#[test]
fn status_scan_enforces_the_aggregate_byte_budget() {
let temp = tempdir().unwrap();
let paths = (0..5)
.map(|index| {
let path = temp.path().join(format!("session-{index}.jsonl"));
let file = fs::File::create(&path).unwrap();
file.set_len(MAX_STATUS_SESSION_FILE_BYTES).unwrap();
path
})
.collect::<Vec<_>>();
let reads = std::cell::Cell::new(0);
let status = source_status(
"fixture",
temp.path(),
paths.len(),
|_, _| (paths.clone(), false),
|_| {
reads.set(reads.get() + 1);
Ok(RawSession::new(
tinycortex::memory::persona::types::EvidenceSource::new(
tinycortex::memory::persona::types::PersonaSourceKind::Codex,
),
))
},
);
assert_eq!(reads.get(), 4);
assert_eq!(status.session_files, 5);
assert!(status.scan_truncated);
}
}
+9
View File
@@ -77,6 +77,12 @@ fn truncate_reason(reason: &str) -> String {
mod tests {
use super::*;
// These tests intentionally mutate the process-global denial buffer. Keep
// their clear/record/assert sequences atomic with respect to one another;
// the parallel libtest runner can otherwise clear a sibling's freshly
// recorded value between `record` and `list`.
static DENIAL_TEST_LOCK: Mutex<()> = Mutex::new(());
fn clear_denials_for_test() {
let mut buf = RECENT_DENIALS.lock().unwrap_or_else(|p| p.into_inner());
buf.clear();
@@ -84,6 +90,7 @@ mod tests {
#[test]
fn record_truncates_and_bounds() {
let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
clear_denials_for_test();
let long = "a".repeat(10_000);
for _ in 0..(MAX_DENIALS + 5) {
@@ -97,6 +104,7 @@ mod tests {
#[test]
fn record_ignores_empty_tool() {
let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
clear_denials_for_test();
record(" ", "policy", "denied", "reason");
// list() should not panic; we can't reliably assert length because tests may run in parallel.
@@ -105,6 +113,7 @@ mod tests {
#[test]
fn record_redacts_sensitive_reason_fragments() {
let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
clear_denials_for_test();
record(
"tool.secret",
+49
View File
@@ -0,0 +1,49 @@
//! Feature contract for TinyCortex Codex/Claude session discovery through the
//! OpenHuman adapter seam.
use std::fs;
use tempfile::tempdir;
use openhuman_core::openhuman::tinycortex::coding_session_status_for_roots;
#[test]
fn coding_session_sources_extract_human_turns_from_both_harnesses() {
let temp = tempdir().expect("tempdir");
let claude_root = temp.path().join("claude/projects/repo");
let codex_root = temp.path().join("codex/sessions/2026/07/14");
fs::create_dir_all(&claude_root).expect("claude root");
fs::create_dir_all(&codex_root).expect("codex root");
fs::write(
claude_root.join("claude-session.jsonl"),
concat!(
"{\"type\":\"user\",\"sessionId\":\"claude-1\",\"cwd\":\"/repo\",\"timestamp\":\"2026-07-14T10:00:00Z\",\"message\":{\"content\":\"Use behavior-driven tests\"}}\n",
"{\"type\":\"user\",\"isSidechain\":true,\"message\":{\"content\":\"subagent machine traffic\"}}\n"
),
)
.expect("claude fixture");
fs::write(
codex_root.join("rollout-codex-session.jsonl"),
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-1\",\"cwd\":\"/repo\"}}\n",
"{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T10:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"machine policy\"}]}}\n",
"{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T10:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Keep modules below 500 lines\"}]}}\n"
),
)
.expect("codex fixture");
let statuses = coding_session_status_for_roots(
&temp.path().join("claude/projects"),
&temp.path().join("codex/sessions"),
);
assert_eq!(statuses.len(), 2);
assert_eq!(statuses[0].kind, "claude_code");
assert_eq!(statuses[0].evidence_units, 1, "sidechain must be excluded");
assert_eq!(statuses[1].kind, "codex");
assert_eq!(
statuses[1].evidence_units, 1,
"developer policy must be excluded"
);
}
@@ -2878,8 +2878,10 @@ async fn worker_a_controller_schemas_are_fully_exposed() {
vec![
"openhuman.memory_sources_add",
"openhuman.memory_sources_apply_all_in",
"openhuman.memory_sources_coding_session_status",
"openhuman.memory_sources_estimate_sync_cost",
"openhuman.memory_sources_get",
"openhuman.memory_sources_ingest_coding_sessions",
"openhuman.memory_sources_list",
"openhuman.memory_sources_list_items",
"openhuman.memory_sources_monthly_cost_summary",
+45
View File
@@ -1113,6 +1113,51 @@ fn ensure_test_rpc_auth() {
});
}
#[tokio::test]
async fn json_rpc_discovers_codex_and_claude_sessions_for_memory_ingestion() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let claude_home = tmp.path().join("claude");
let codex_home = tmp.path().join("codex");
let claude_root = claude_home.join("projects/repo");
let codex_root = codex_home.join("sessions/2026/07/14");
std::fs::create_dir_all(&claude_root).expect("claude fixture root");
std::fs::create_dir_all(&codex_root).expect("codex fixture root");
std::fs::write(
claude_root.join("session.jsonl"),
"{\"type\":\"user\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"message\":{\"content\":\"Prefer focused tests\"}}\n",
)
.expect("claude fixture");
std::fs::write(
codex_root.join("rollout-session.jsonl"),
"{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Prefer small modules\"}]}}\n",
)
.expect("codex fixture");
let _claude_guard = EnvVarGuard::set_to_path("CLAUDE_CONFIG_DIR", &claude_home);
let _codex_guard = EnvVarGuard::set_to_path("CODEX_HOME", &codex_home);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
let response = post_json_rpc(
&rpc_base,
4_914_001,
"openhuman.memory_sources_coding_session_status",
json!({}),
)
.await;
let result = peel_logs_envelope(assert_no_jsonrpc_error(
&response,
"memory_sources_coding_session_status",
));
let sources = result["sources"].as_array().expect("sources array");
assert_eq!(sources.len(), 2);
assert!(sources.iter().all(|source| source["session_files"] == 1));
assert!(sources.iter().all(|source| source["evidence_units"] == 1));
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_config_update_browser_settings_persists_backend() {
let _env_lock = json_rpc_e2e_env_lock();
@@ -484,13 +484,16 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() {
while let Ok(event) = progress_rx.try_recv() {
streamed.push(event);
}
assert!(!streamed.iter().any(|event| matches!(
event,
openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta {
delta,
iteration: 2
} if delta == "checkpoint delta"
)), "rejected checkpoint deltas must not leak to progress consumers");
assert!(
!streamed.iter().any(|event| matches!(
event,
openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta {
iteration: 2,
..
}
)),
"wrap-up deltas must stay buffered when the completed response contains an invalid tool call"
);
}
#[tokio::test]
@@ -270,6 +270,9 @@ fn memory_tree_types_and_fallback_summary_cover_budget_and_legacy_parse_paths()
tree_kind: openhuman_core::openhuman::memory_store::trees::types::TreeKind::Global,
target_level: 2,
token_budget: 128,
input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET,
overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS,
ask: None,
};
assert_eq!(ctx.tree_id, "tree-coverage");
assert_eq!(ctx.target_level, 2);
@@ -167,7 +167,7 @@ async fn round23_memory_sources_status_registry_and_readers_cover_remaining_edge
MemorySourcePatch {
label: Some("Round23 Folder Updated".to_string()),
enabled: Some(false),
glob: Some("**/*.md".to_string()),
glob: Some(Some("**/*.md".to_string())),
..MemorySourcePatch::default()
},
)
@@ -166,7 +166,7 @@ async fn memory_sources_registry_persists_crud_and_composio_upserts() {
MemorySourcePatch {
label: Some("Renamed notes".to_string()),
enabled: Some(false),
glob: Some("*.txt".to_string()),
glob: Some(Some("*.txt".to_string())),
..MemorySourcePatch::default()
},
)
@@ -513,6 +513,7 @@ fn seed_source_summary(
id: format!("tree:{summary_id}"),
kind: TreeKind::Source,
scope: scope.to_string(),
ask: None,
root_id: Some(summary_id.to_string()),
max_level: 1,
status: TreeStatus::Active,
@@ -1601,11 +1601,18 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() {
let summaries = tree_runtime_store::collect_root_summaries_with_caps(tmp.path(), 10, 12);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].0, "slack_#eng");
let stored_namespace = tree_runtime_store::tree_dir(&config, namespace)
.parent()
.and_then(std::path::Path::file_name)
.and_then(std::ffi::OsStr::to_str)
.expect("sanitized namespace directory")
.to_string();
assert!(stored_namespace.starts_with("slack_#eng-"));
assert_eq!(summaries[0].0, stored_namespace);
assert!(summaries[0].1.contains("[... truncated]"));
assert_eq!(
tree_runtime_store::list_namespaces_with_root(&config).unwrap(),
vec!["slack_#eng".to_string()]
vec![stored_namespace]
);
let ts = Utc.with_ymd_and_hms(2026, 5, 29, 13, 0, 0).unwrap();
@@ -1921,6 +1928,9 @@ async fn memory_read_rpc_score_index_and_summary_helpers_cover_dashboard_paths()
tree_kind: TreeKind::Global,
target_level: 1,
token_budget: 100,
input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET,
overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS,
ask: None,
};
let empty =
openhuman_core::openhuman::memory_tree::summarise::summarise(&config, &[], &empty_ctx)
@@ -1967,6 +1977,7 @@ fn memory_retrieval_embedding_and_rpc_model_helpers_round_trip() {
id: "tree-1".into(),
kind: TreeKind::Topic,
scope: "topic:coverage".into(),
ask: None,
root_id: Some("sum-1".into()),
max_level: 2,
status: StoredTreeStatus::Active,
@@ -2071,7 +2082,7 @@ fn memory_retrieval_embedding_and_rpc_model_helpers_round_trip() {
score: Some(0.9),
taint: Default::default(),
};
assert_eq!(entry.category.to_string(), "testing");
assert_eq!(entry.category.to_string(), "custom:testing");
let opts = RecallOpts {
namespace: Some("default"),
category: Some(MemoryCategory::Conversation),
@@ -2777,6 +2788,7 @@ fn memory_tree_io_contract_types_round_trip_leaf_read_and_write_shapes() {
id: "empty-tree".into(),
kind: TreeKind::Source,
scope: "source:contract".into(),
ask: None,
root_id: None,
max_level: 0,
status: StoredTreeStatus::Active,
@@ -3951,8 +3963,7 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() {
patch: serde_json::from_value(json!({
"label": "Disabled folder",
"enabled": false,
"glob": "**/*.md",
"max_items": 2
"glob": "**/*.md"
}))
.expect("patch"),
})
@@ -4698,17 +4709,15 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e
let patch: registry::MemorySourcePatch = serde_json::from_value(json!({
"label": "Updated repo",
"enabled": false,
"toolkit": "github",
"connection_id": "conn_repo",
"path": "/tmp/repo",
"glob": "**/*.md",
"url": "https://github.com/tinyhumansai/openhuman-skills",
"branch": "main",
"paths": ["skills", "README.md"],
"query": "is:open",
"since_days": 14,
"max_items": 9,
"selector": "main"
"max_tokens_per_sync": 1000,
"max_cost_per_sync_usd": 0.5,
"sync_depth_days": 30,
"max_commits": 5,
"max_issues": 6,
"max_prs": 7
}))
.expect("patch");
let updated = registry::update_source("src_repo", patch)
@@ -4716,20 +4725,18 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e
.expect("update repo source");
assert_eq!(updated.label, "Updated repo");
assert!(!updated.enabled);
assert_eq!(updated.toolkit.as_deref(), Some("github"));
assert_eq!(updated.connection_id.as_deref(), Some("conn_repo"));
assert_eq!(updated.path.as_deref(), Some("/tmp/repo"));
assert_eq!(updated.glob.as_deref(), Some("**/*.md"));
assert_eq!(
updated.url.as_deref(),
Some("https://github.com/tinyhumansai/openhuman-skills")
);
assert_eq!(updated.branch.as_deref(), Some("main"));
assert_eq!(updated.paths, vec!["skills", "README.md"]);
assert_eq!(updated.query.as_deref(), Some("is:open"));
assert_eq!(updated.since_days, Some(14));
assert_eq!(updated.max_items, Some(9));
assert_eq!(updated.selector.as_deref(), Some("main"));
assert_eq!(updated.max_tokens_per_sync, Some(1000));
assert_eq!(updated.max_cost_per_sync_usd, Some(0.5));
assert_eq!(updated.sync_depth_days, Some(30));
assert_eq!(updated.max_commits, Some(5));
assert_eq!(updated.max_issues, Some(6));
assert_eq!(updated.max_prs, Some(7));
let memory = Arc::new(
MemoryClient::from_workspace_dir(tmp.path().join("memory-sync-state"))
@@ -169,6 +169,7 @@ fn seed_topic_summary(
id: format!("tree:{summary_id}"),
kind: TreeKind::Topic,
scope: entity_id.to_string(),
ask: None,
root_id: Some(summary_id.to_string()),
max_level: 2,
status: TreeStatus::Active,