diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index a00dc98a5..77839de64 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -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 diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 9b1c1bb22..f72db3a66 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -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 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index b9f4ee4a8..b25ca73d2 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -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 diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index c1723ea73..dc1c44310 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -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) diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 04265f828..c87b3de08 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 9dd7bc0f7..bdd73c117 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 687d98be9..8c436dd53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index ff473e248..2bfa50d8e 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -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" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 80bcd618f..4b69f599e 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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", ] diff --git a/app/src/components/intelligence/CodingSessionsCard.tsx b/app/src/components/intelligence/CodingSessionsCard.tsx new file mode 100644 index 000000000..c9f7f33d5 --- /dev/null +++ b/app/src/components/intelligence/CodingSessionsCard.tsx @@ -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) => void; +} + +const SOURCE_LABEL_KEYS: Record = { + claude_code: 'memorySources.codingSessions.claude', + codex: 'memorySources.codingSessions.codex', +}; + +export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) { + const { t } = useT(); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [ingesting, setIngesting] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+

+ {t('memorySources.codingSessions.title')} +

+

+ {t('memorySources.codingSessions.description')} +

+
+ +
+ +
+ {sources.map(source => ( +
+
+ {t(SOURCE_LABEL_KEYS[source.kind])} +
+
+ {source.available + ? t('memorySources.codingSessions.counts') + .replace('{files}', String(source.session_files)) + .replace('{evidence}', String(source.evidence_units)) + : t('memorySources.codingSessions.notFound')} +
+ {source.available && source.scan_truncated && ( +
+ {t('memorySources.codingSessions.truncated')} +
+ )} +
+ ))} +
+ + {loading && ( +

+ {t('memorySources.codingSessions.scanning')} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx new file mode 100644 index 000000000..0eea7131f --- /dev/null +++ b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx @@ -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( + '../../../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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByTestId('coding-sessions-ingest')).toBeEnabled(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9b9662d40..9488af5f3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'يدوي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 1e34ff6cc..601efd3ce 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'ম্যানুয়াল', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 871f54a66..09ea9cdfe 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0291d524c..d74883b67 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index b41d05659..45921f527 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4d34ed6df..e3a8e1d5d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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 d’agents 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 l’historique local…', + 'memorySources.codingSessions.truncated': + 'L’analyse 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 l’ingestion pour réessayer.', + 'memorySources.codingSessions.moreRemaining': + 'La limite de sessions du lot a été atteinte. Relancez l’ingestion pour continuer à importer votre historique.', + 'memorySources.codingSessions.failed': 'Échec de l’ingestion des sessions de programmation', 'flows.canvas.sidePanelToggle': 'Panneau latéral', 'flows.canvas.legendTab': 'Manuel', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ce2250f92..73799ce91 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'मैनुअल', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 2c9eddeca..e18630330 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 6ca7a6820..55ff7e211 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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 l’acquisizione per riprovare.', + 'memorySources.codingSessions.moreRemaining': + 'È stato raggiunto il limite di sessioni del batch. Avvia di nuovo l’acquisizione 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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0f484310e..3ad8643e3 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '수동', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 71a6700c0..f67a8ffaa 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f88efaeb9..76ee0c2b1 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 45ea25914..e3c1319a0 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Вручную', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 517b1b8fc..2a411462b 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '手动', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index e803a1b84..291493ad5 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -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' && (
+
)} diff --git a/app/src/services/memorySourcesService.test.ts b/app/src/services/memorySourcesService.test.ts index ff4519644..ac2b3ca81 100644 --- a/app/src/services/memorySourcesService.test.ts +++ b/app/src/services/memorySourcesService.test.ts @@ -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); + }); }); diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts index bcd646a5b..82db7ac96 100644 --- a/app/src/services/memorySourcesService.ts +++ b/app/src/services/memorySourcesService.ts @@ -201,6 +201,78 @@ export async function applyAllIn(): Promise { 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 { + 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 { + 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({ + method: 'openhuman.memory_sources_ingest_coding_sessions', + params: { backfill, max_sessions: boundedMaxSessions }, + timeoutMs, + }); + const data = unwrap(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 diff --git a/app/test/e2e/specs/coding-session-memory.spec.ts b/app/test/e2e/specs/coding-session-memory.spec.ts new file mode 100644 index 000000000..365e04b65 --- /dev/null +++ b/app/test/e2e/specs/coding-session-memory.spec.ts @@ -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(); + }); +}); diff --git a/app/test/playwright/specs/coding-session-memory.spec.ts b/app/test/playwright/specs/coding-session-memory.spec.ts new file mode 100644 index 000000000..88bf35d56 --- /dev/null +++ b/app/test/playwright/specs/coding-session-memory.spec.ts @@ -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(); + }); +}); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 0bc8f16b9..171ad792a 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -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`) | @@ -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. | --- diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index a5ce31534..fb9bad1b2 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -14,6 +14,12 @@ const DERIVED_TO_BACKEND: Option = Some(CapabilityPrivacy { destinations: &["OpenHuman backend", "TinyHumans Neocortex"], }); +const CODING_SESSION_TO_BACKEND: Option = 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 = 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", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 8618cf65a..ca5fcb424 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -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 diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index ab7cf2d5b..ce670be3b 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -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(""); diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index e98911a9a..195d7ccc8 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -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) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index c837f21c9..5323af2cb 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -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>, 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, + tools: Vec>, + memory_loader: Box, + post_turn_hooks: Vec>, + config: crate::openhuman::config::AgentConfig, + context_config: crate::openhuman::config::ContextConfig, + tool_dispatcher: Box, ) -> 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 = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + Ok(ChatResponse { + text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), + ..ChatResponse::default() + }), + Ok(ChatResponse { + text: Some("echo[]".into()), + ..ChatResponse::default() + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let tools: Vec> = 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 diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index a2ce40467..be050c62b 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -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; diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 5a8849f03..ec9779480 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -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"); diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 448b35fb1..319445e1d 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -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, } } diff --git a/src/openhuman/memory/tools/store.rs b/src/openhuman/memory/tools/store.rs index deda5b58f..afe784563 100644 --- a/src/openhuman/memory/tools/store.rs +++ b/src/openhuman/memory/tools/store.rs @@ -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:` + // wire value — the form `memory_recall`/`Display` now emit — resolves + // back to `Custom("")` instead of `Custom("custom:")` + // (which would `Display` as `custom:custom:` 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:` wire value (the form `memory_recall` and + /// `Display` now emit) must store as `Custom("")`, not the + /// double-prefixed `Custom("custom:")` — otherwise it would `Display` + /// as `custom:custom:` 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(); diff --git a/src/openhuman/memory/traits.rs b/src/openhuman/memory/traits.rs index 6c63498e0..3d6040425 100644 --- a/src/openhuman/memory/traits.rs +++ b/src/openhuman/memory/traits.rs @@ -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(¤t).unwrap(), + "\"custom:project_notes\"" ); } diff --git a/src/openhuman/memory/tree_source/file.rs b/src/openhuman/memory/tree_source/file.rs index c7981150a..080b17a58 100644 --- a/src/openhuman/memory/tree_source/file.rs +++ b/src/openhuman/memory/tree_source/file.rs @@ -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, diff --git a/src/openhuman/memory_queue/worker.rs b/src/openhuman/memory_queue/worker.rs index 67b613745..eb7b4835e 100644 --- a/src/openhuman/memory_queue/worker.rs +++ b/src/openhuman/memory_queue/worker.rs @@ -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!( diff --git a/src/openhuman/memory_search/tools/hybrid_search.rs b/src/openhuman/memory_search/tools/hybrid_search.rs index 462c4d777..eda4b4698 100644 --- a/src/openhuman/memory_search/tools/hybrid_search.rs +++ b/src/openhuman/memory_search/tools/hybrid_search.rs @@ -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}"); + } +} diff --git a/src/openhuman/memory_search/tools/vector_search.rs b/src/openhuman/memory_search/tools/vector_search.rs index ad04fa412..4fa106551 100644 --- a/src/openhuman/memory_search/tools/vector_search.rs +++ b/src/openhuman/memory_search/tools/vector_search.rs @@ -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, }; diff --git a/src/openhuman/memory_sources/rpc.rs b/src/openhuman/memory_sources/rpc.rs index 191011594..64f41fc67 100644 --- a/src/openhuman/memory_sources/rpc.rs +++ b/src/openhuman/memory_sources/rpc.rs @@ -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, +} + +pub async fn coding_session_status_rpc() -> Result, 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::(), + "[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, 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)] diff --git a/src/openhuman/memory_sources/schemas.rs b/src/openhuman/memory_sources/schemas.rs index 176a733c2..f3f8c46a4 100644 --- a/src/openhuman/memory_sources/schemas.rs +++ b/src/openhuman/memory_sources/schemas.rs @@ -135,6 +135,8 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { Box::pin(async move { to_json(rpc::apply_all_in_rpc().await?) }) } +fn handle_coding_session_status(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(rpc::coding_session_status_rpc().await?) }) +} + +fn handle_ingest_coding_sessions(params: Map) -> ControllerFuture { + Box::pin(async move { + let req = parse_value::( + Value::Object(params), + )?; + to_json(rpc::ingest_coding_sessions_rpc(req).await?) + }) +} + fn parse_value(v: Value) -> Result { serde_json::from_value(v).map_err(|e| format!("invalid params: {e}")) } diff --git a/src/openhuman/memory_store/memory_trait.rs b/src/openhuman/memory_store/memory_trait.rs index d209aa04a..64f9d2cbc 100644 --- a/src/openhuman/memory_store/memory_trait.rs +++ b/src/openhuman/memory_store/memory_trait.rs @@ -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] diff --git a/src/openhuman/memory_store/retrieval/mod.rs b/src/openhuman/memory_store/retrieval/mod.rs index 1e7fd632b..b819362b6 100644 --- a/src/openhuman/memory_store/retrieval/mod.rs +++ b/src/openhuman/memory_store/retrieval/mod.rs @@ -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, }; diff --git a/src/openhuman/memory_store/tools/raw_chunks.rs b/src/openhuman/memory_store/tools/raw_chunks.rs index cdcc3ef43..88ca2e03a 100644 --- a/src/openhuman/memory_store/tools/raw_chunks.rs +++ b/src/openhuman/memory_store/tools/raw_chunks.rs @@ -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, }; diff --git a/src/openhuman/memory_store/traits.rs b/src/openhuman/memory_store/traits.rs index 7a5819fc7..2b9002a56 100644 --- a/src/openhuman/memory_store/traits.rs +++ b/src/openhuman/memory_store/traits.rs @@ -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, diff --git a/src/openhuman/memory_store/trees/store_tests.rs b/src/openhuman/memory_store/trees/store_tests.rs index e498710ef..50f929ce1 100644 --- a/src/openhuman/memory_store/trees/store_tests.rs +++ b/src/openhuman/memory_store/trees/store_tests.rs @@ -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, diff --git a/src/openhuman/memory_tree/tree/registry.rs b/src/openhuman/memory_tree/tree/registry.rs index d11ff6e94..a317664b3 100644 --- a/src/openhuman/memory_tree/tree/registry.rs +++ b/src/openhuman/memory_tree/tree/registry.rs @@ -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, diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index c277a850f..03be61a56 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -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, }; diff --git a/src/openhuman/tinycortex/ingest.rs b/src/openhuman/tinycortex/ingest.rs index 0420013e8..fac6dd00e 100644 --- a/src/openhuman/tinycortex/ingest.rs +++ b/src/openhuman/tinycortex/ingest.rs @@ -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 { + 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), ) } diff --git a/src/openhuman/tinycortex/mod.rs b/src/openhuman/tinycortex/mod.rs index b013e42d9..99d5a36d8 100644 --- a/src/openhuman/tinycortex/mod.rs +++ b/src/openhuman/tinycortex/mod.rs @@ -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, }; diff --git a/src/openhuman/tinycortex/parity.rs b/src/openhuman/tinycortex/parity.rs index aed98124f..e152c17de 100644 --- a/src/openhuman/tinycortex/parity.rs +++ b/src/openhuman/tinycortex/parity.rs @@ -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 diff --git a/src/openhuman/tinycortex/persona.rs b/src/openhuman/tinycortex/persona.rs new file mode 100644 index 000000000..0438ee2da --- /dev/null +++ b/src/openhuman/tinycortex/persona.rs @@ -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, +} + +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, bool), + read: impl Fn(&Path) -> anyhow::Result, +) -> 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, 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, 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, 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 { + 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::(), + evidence = statuses + .iter() + .map(|status| status.evidence_units) + .sum::(), + invalid = statuses + .iter() + .map(|status| status.invalid_files) + .sum::(), + "[memory_persona] coding session scan: exit" + ); + statuses +} + +pub fn coding_session_status() -> Vec { + 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 { + 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::>(); + 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); + } +} diff --git a/src/openhuman/tool_registry/denials.rs b/src/openhuman/tool_registry/denials.rs index 4e6ae832b..9c6a1f6cb 100644 --- a/src/openhuman/tool_registry/denials.rs +++ b/src/openhuman/tool_registry/denials.rs @@ -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", diff --git a/tests/coding_sessions_feature.rs b/tests/coding_sessions_feature.rs new file mode 100644 index 000000000..e63f54b75 --- /dev/null +++ b/tests/coding_sessions_feature.rs @@ -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" + ); +} diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 33b506465..841563d16 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -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", diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index bcd7c0e0d..73580df66 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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(); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 37de29f21..05156edc6 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -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] diff --git a/tests/raw_coverage/memory_raw_coverage_e2e.rs b/tests/raw_coverage/memory_raw_coverage_e2e.rs index 5ca4c65b1..cd9b2777d 100644 --- a/tests/raw_coverage/memory_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_raw_coverage_e2e.rs @@ -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); diff --git a/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs index 1c5d01732..6eaebb9e7 100644 --- a/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs @@ -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() }, ) diff --git a/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs index 82ddecd2f..1b1b052ca 100644 --- a/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs @@ -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() }, ) diff --git a/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs index 538d652b9..e6fdc9434 100644 --- a/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs @@ -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, diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index ce3f47801..890267a67 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -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")) diff --git a/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs b/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs index bee2ea04e..2342c7f2a 100644 --- a/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs @@ -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, diff --git a/vendor/tinycortex b/vendor/tinycortex index 671e78a01..9a0603afb 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 671e78a01411de5bcda8f3d1816ac6c67485d694 +Subproject commit 9a0603afbebac608eac2ca0fa606caecd31ed1e7