From 7ebf1cb268493041ec4e5feb77db13bef1a620be Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Wed, 3 Jun 2026 18:46:21 +0530 Subject: [PATCH 01/11] feat(memory-sources): default sync sources on with conservative caps, per-source settings & All In Memory sync sources now register enabled by default with conservative, per-kind caps that are actually enforced in the Composio provider sync path, plus a per-source settings UI and an "All In" opt-out. Core (Rust): - composio_defaults_for_toolkit(): single source of truth for conservative per-toolkit caps (gmail 100/30d, slack 50/14d, notion 30/30d, linear 50/30d, clickup 50/30d, github 50/30d, generic fallback 30/14d). - upsert_composio_source now registers enabled:true with caps applied. - apply_kind_defaults(): GithubRepo 10 PR/10 issue/50 commit, RSS 20, Twitter 7d. - ProviderContext carries max_items/sync_depth_days; populated at all three construction sites (run_connection_sync, periodic, bus). All six providers (gmail/slack/notion/linear/clickup/github) enforce the caps on pagination and the date window via shared pages_for_max_items/epoch_floor_from_depth. - New RPC memory_sources_apply_all_in: enables all sources, clears all caps, triggers sync per source. - One-time retroactive migration (apply_composio_source_caps_migration), guarded by Config.composio_source_caps_migrated, only touches uncustomized disabled Composio sources. - Fix: MemorySourcePatch was missing max_commits/max_issues/max_prs. App (React): - Per-source settings as an expandable row (gear) with kind-aware limit fields, saved via memory_sources_update. - "All In" header button -> ConfirmationModal -> applyAllIn(). - Fix incomplete TS MemorySourceEntry interface; add applyAllIn() service fn. - New i18n keys in en.ts + real translations across all 13 locales. Tests: Rust unit tests (defaults, apply_all_in, migration, patch, cap helpers); new MemorySourcesRegistry.test.tsx (17); extended memorySourcesService.test.ts. Closes #3293 --- .claude/memory.md | 12 + .../intelligence/MemorySourcesRegistry.tsx | 156 ++++++++- .../intelligence/SourceSettingsPanel.tsx | 161 +++++++++ .../__tests__/MemorySourcesRegistry.test.tsx | 322 ++++++++++++++++++ app/src/lib/i18n/ar.ts | 23 ++ app/src/lib/i18n/bn.ts | 23 ++ app/src/lib/i18n/de.ts | 23 ++ app/src/lib/i18n/en.ts | 23 ++ app/src/lib/i18n/es.ts | 24 ++ app/src/lib/i18n/fr.ts | 23 ++ app/src/lib/i18n/hi.ts | 23 ++ app/src/lib/i18n/id.ts | 23 ++ app/src/lib/i18n/it.ts | 24 ++ app/src/lib/i18n/ko.ts | 23 ++ app/src/lib/i18n/pl.ts | 24 ++ app/src/lib/i18n/pt.ts | 23 ++ app/src/lib/i18n/ru.ts | 25 ++ app/src/lib/i18n/zh-CN.ts | 23 ++ app/src/services/memorySourcesService.test.ts | 71 ++++ app/src/services/memorySourcesService.ts | 28 +- src/bin/slack_backfill.rs | 4 + src/openhuman/composio/ops.rs | 6 + src/openhuman/config/schema/types.rs | 10 + src/openhuman/memory_sources/mod.rs | 7 +- src/openhuman/memory_sources/reconcile.rs | 196 +++++++++++ src/openhuman/memory_sources/registry.rs | 158 ++++++++- src/openhuman/memory_sources/rpc.rs | 112 +++++- src/openhuman/memory_sources/schemas.rs | 52 ++- src/openhuman/memory_sync/composio/bus.rs | 32 +- src/openhuman/memory_sync/composio/mod.rs | 24 ++ .../memory_sync/composio/periodic.rs | 25 ++ .../composio/providers/clickup/provider.rs | 60 +++- .../composio/providers/github/provider.rs | 102 +++++- .../composio/providers/gmail/provider.rs | 108 +++++- .../memory_sync/composio/providers/helpers.rs | 54 +++ .../composio/providers/linear/provider.rs | 59 +++- .../composio/providers/notion/provider.rs | 64 +++- .../composio/providers/slack/provider.rs | 27 +- .../composio/providers/slack/rpc.rs | 2 + .../memory_sync/composio/providers/types.rs | 20 ++ src/openhuman/task_sources/ops.rs | 2 + src/openhuman/task_sources/pipeline.rs | 2 + 42 files changed, 2171 insertions(+), 32 deletions(-) create mode 100644 app/src/components/intelligence/SourceSettingsPanel.tsx create mode 100644 app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx diff --git a/.claude/memory.md b/.claude/memory.md index 4e50acc1be3..50ea71a6000 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -300,3 +300,15 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Upstream `main` has 5 Vitest failures and 4 TypeScript compile errors** — Caused by missing iOS experimental dependencies: `@noble/ciphers/chacha`, `@noble/ciphers/webcrypto`, `qrcode.react`, `@tauri-apps/plugin-barcode-scanner`. Breaks `pnpm compile`, `pnpm build`, `pnpm test:coverage` on a clean checkout. Always verify by stashing changes and running checks on the base branch before blaming your PR. - **`cargo fmt` must run after codecrusher** — codecrusher does not reliably produce `cargo fmt`-clean Rust. Always run `cargo fmt --manifest-path Cargo.toml` after codecrusher finishes and before committing. + +## Memory Sync Sources — Defaults ON + Per-Source UI (Issue #3293) + +- **Conservative caps registry** — `composio_defaults_for_toolkit(toolkit) -> (max_items, sync_depth_days)` in `src/openhuman/memory_sources/registry.rs` is the single source of truth. Values: gmail 100/30, slack 50/14, notion 30/30, linear 50/30, clickup 50/30, github 50/30, generic 30/14. Non-Composio defaults (GithubRepo 10PR/10issue/50commit, RSS 20, Twitter 7d) live in `apply_kind_defaults` in `rpc.rs`. +- **`upsert_composio_source` now defaults ON** — Registers `enabled: true` with caps applied from the registry. Previously registered `enabled: false` with no caps. +- **Cap enforcement: 3 construction sites, not 1** — `ProviderContext` (`memory_sync/composio/providers/types.rs`) carries `max_items`/`sync_depth_days`. All three sites must populate caps from the registry entry: `composio/mod.rs run_connection_sync`, `composio/periodic.rs`, `composio/bus.rs`. Each provider reads `ctx.max_items`/`ctx.sync_depth_days` for pagination + date clamping. Shared helpers: `pages_for_max_items` / `epoch_floor_from_depth` in `providers/helpers.rs`. +- **First-sync gotcha on new connections** — `bus.rs on_connection_created` fires BEFORE `upsert_composio_source`, so caps are (None, None) on the brand-new connection's first sync — bounded only by internal `MAX_PAGES`, not registry caps. Documented in code; intentional. +- **`memory_sources_apply_all_in` RPC** — Zero params → `{ sources, sync_triggered }`. Enables all sources, clears caps to None (falls back to internal `MAX_PAGES` ceilings, ~500 items, not truly unlimited), triggers sync per source. +- **Retroactive migration** — `apply_composio_source_caps_migration` in `reconcile.rs`, guarded by `Config.composio_source_caps_migrated: bool` (`#[serde(default)]`), runs once from `ensure_composio_sources`. Only touches Composio entries with `!enabled && max_items.is_none() && sync_depth_days.is_none()` — never overwrites user-customized caps. +- **`MemorySourcePatch` was missing limit fields** — `max_commits`, `max_issues`, `max_prs` were absent from `MemorySourcePatch`, `update_source`, and the update schema. Also missing from the TS `MemorySourceEntry` interface in `memorySourcesService.ts`. Both were fixed in this issue. +- **Per-source settings UI** — `SourceSettingsPanel.tsx` (sibling of `MemorySourcesRegistry.tsx`) with a `KIND_FIELDS` map driving which limit fields appear per source kind. Empty input = omit from patch (use default); number = set. "All In" button uses `ConfirmationModal` (primary-500 prominent). Toasts via existing `onToast` prop. +- **`pnpm i18n:english:check` is pre-failing** — Exits with `total unexpected English: 1312` on a clean base tree. Confirm your new keys aren't among the failures rather than expecting exit 0. `pnpm i18n:check` (key parity) is the real gate. diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 988a6e0f0bd..755c173b720 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { + applyAllIn, type FreshnessLabel, listMemorySources, type MemorySourceEntry, @@ -23,9 +24,14 @@ import { syncMemorySource, updateMemorySource, } from '../../services/memorySourcesService'; -import type { ToastNotification } from '../../types/intelligence'; +import type { + ConfirmationModal as ConfirmationModalType, + ToastNotification, +} from '../../types/intelligence'; import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree'; import { AddMemorySourceDialog } from './AddMemorySourceDialog'; +import { ConfirmationModal } from './ConfirmationModal'; +import { SourceSettingsPanel } from './SourceSettingsPanel'; interface MemorySourcesRegistryProps { onToast?: (toast: Omit) => void; @@ -59,6 +65,9 @@ export function MemorySourcesRegistry({ const [syncingId, setSyncingId] = useState(null); const [buildingId, setBuildingId] = useState(null); const [syncProgress, setSyncProgress] = useState>(new Map()); + const [allInModalOpen, setAllInModalOpen] = useState(false); + const [applyingAllIn, setApplyingAllIn] = useState(false); + const [expandedSettingsId, setExpandedSettingsId] = useState(null); useEffect(() => { const handler = (e: Event) => { @@ -223,23 +232,81 @@ export function MemorySourcesRegistry({ [onToast, refresh, t] ); + const handleConfirmAllIn = useCallback(async () => { + setApplyingAllIn(true); + try { + const result = await applyAllIn(); + setSources(result.sources); + onToast?.({ type: 'success', title: t('memorySources.allIn.success') }); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySources.allIn.failed'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setApplyingAllIn(false); + setAllInModalOpen(false); + } + }, [onToast, t]); + + const handleSettingsSaved = useCallback((updated: MemorySourceEntry) => { + setSources(prev => prev.map(s => (s.id === updated.id ? updated : s))); + }, []); + + const handleToggleSettings = useCallback((sourceId: string) => { + setExpandedSettingsId(prev => (prev === sourceId ? null : sourceId)); + }, []); + + const allInModal: ConfirmationModalType = { + isOpen: allInModalOpen, + title: t('memorySources.allIn.title'), + message: t('memorySources.allIn.message'), + confirmText: t('memorySources.allIn.confirm'), + cancelText: t('memorySources.allIn.cancel'), + destructive: false, + onConfirm: () => { + void handleConfirmAllIn(); + }, + onCancel: () => { + setAllInModalOpen(false); + }, + }; + return (
-
+

{t('memorySources.title')}

- +
+ + +
{loading ? ( @@ -256,10 +323,14 @@ export function MemorySourcesRegistry({ isSyncing={syncingId === source.id} isBuilding={buildingId === source.id} progress={syncProgress.get(source.id) ?? null} + settingsExpanded={expandedSettingsId === source.id} onToggle={handleToggle} onRemove={handleRemove} onSync={handleSync} onBuild={handleBuild} + onToggleSettings={handleToggleSettings} + onSettingsSaved={handleSettingsSaved} + onToast={onToast} /> ))} @@ -270,6 +341,10 @@ export function MemorySourcesRegistry({ onClose={() => setDialogOpen(false)} onAdded={handleAdded} /> + + {allInModalOpen && ( + setAllInModalOpen(false)} /> + )}
); } @@ -280,10 +355,14 @@ interface SourceRowProps { isSyncing: boolean; isBuilding: boolean; progress: SyncProgress | null; + settingsExpanded: boolean; onToggle: (source: MemorySourceEntry) => void; onRemove: (source: MemorySourceEntry) => void; onSync: (source: MemorySourceEntry) => void; onBuild: (source: MemorySourceEntry) => void; + onToggleSettings: (sourceId: string) => void; + onSettingsSaved: (updated: MemorySourceEntry) => void; + onToast?: (toast: Omit) => void; } function SourceRow({ @@ -292,10 +371,14 @@ function SourceRow({ isSyncing, isBuilding, progress, + settingsExpanded, onToggle, onRemove, onSync, onBuild, + onToggleSettings, + onSettingsSaved, + onToast, }: SourceRowProps) { const { t } = useT(); const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄'; @@ -372,6 +455,19 @@ function SourceRow({ )}
+
+ {settingsExpanded && ( + + )} ); } @@ -573,3 +672,38 @@ function Spinner() { ); } + +function GearIcon() { + return ( + + ); +} + +function AllInIcon() { + return ( + + ); +} diff --git a/app/src/components/intelligence/SourceSettingsPanel.tsx b/app/src/components/intelligence/SourceSettingsPanel.tsx new file mode 100644 index 00000000000..74f28094884 --- /dev/null +++ b/app/src/components/intelligence/SourceSettingsPanel.tsx @@ -0,0 +1,161 @@ +/** + * Expandable per-source sync-settings panel. + * + * Rendered inline below a SourceRow when the gear icon is clicked. + * Shows only the limit fields relevant to the source's kind, seeded from + * the current entry values. Empty input = omit the field on save (meaning + * "use backend default / unlimited"). + * + * On Save calls `updateMemorySource` and notifies the parent via + * `onSaved` with the updated entry. + */ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + type MemorySourceEntry, + type SourceKind, + updateMemorySource, +} from '../../services/memorySourcesService'; + +// Which limit fields are relevant per kind. Order determines display order. +const KIND_FIELDS: Record> = { + composio: ['sync_depth_days', 'max_items', 'max_tokens_per_sync', 'max_cost_per_sync_usd'], + github_repo: [ + 'max_prs', + 'max_issues', + 'max_commits', + 'sync_depth_days', + 'max_tokens_per_sync', + 'max_cost_per_sync_usd', + ], + rss_feed: ['max_items', 'sync_depth_days'], + twitter_query: ['since_days', 'max_tokens_per_sync'], + web_page: ['sync_depth_days', 'max_tokens_per_sync'], + folder: ['sync_depth_days'], +}; + +// i18n key for each field label +const FIELD_LABEL_KEYS: Record = { + max_prs: 'memorySources.settings.maxPrs', + max_issues: 'memorySources.settings.maxIssues', + max_commits: 'memorySources.settings.maxCommits', + max_items: 'memorySources.settings.maxItems', + since_days: 'memorySources.settings.sinceDays', + sync_depth_days: 'memorySources.settings.syncDepthDays', + max_tokens_per_sync: 'memorySources.settings.maxTokens', + max_cost_per_sync_usd: 'memorySources.settings.maxCost', +}; + +type LimitFields = Pick< + MemorySourceEntry, + | 'max_prs' + | 'max_issues' + | 'max_commits' + | 'max_items' + | 'since_days' + | 'sync_depth_days' + | 'max_tokens_per_sync' + | 'max_cost_per_sync_usd' +>; + +interface SourceSettingsPanelProps { + source: MemorySourceEntry; + onSaved: (updated: MemorySourceEntry) => void; + onToast?: (toast: { type: 'success' | 'error'; title: string; message?: string }) => void; +} + +export function SourceSettingsPanel({ source, onSaved, onToast }: SourceSettingsPanelProps) { + const { t } = useT(); + const fields = KIND_FIELDS[source.kind] ?? []; + + // Hold each field as a string so inputs can be freely edited + const [values, setValues] = useState>(() => { + const init: Record = {}; + for (const f of fields) { + const v = source[f as keyof MemorySourceEntry]; + init[f] = v != null ? String(v) : ''; + } + return init; + }); + + const [saving, setSaving] = useState(false); + + const handleChange = useCallback((field: string, value: string) => { + setValues(prev => ({ ...prev, [field]: value })); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const patch: Partial = {}; + for (const f of fields) { + const raw = values[f]; + if (raw !== '' && raw !== undefined) { + (patch as Record)[f] = Number(raw); + } + // Empty string → omit from patch (backend treats absence as "default") + } + const updated = await updateMemorySource(source.id, patch); + onSaved(updated); + onToast?.({ type: 'success', title: t('memorySources.settings.saved') }); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySources.settings.saveFailed'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setSaving(false); + } + }, [fields, source.id, values, onSaved, onToast, t]); + + if (fields.length === 0) return null; + + return ( +
+

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

+
+ {fields.map(field => ( +
+ + handleChange(field, e.target.value)} + placeholder={t('memorySources.settings.unlimitedHint')} + className="w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-xs font-mono + text-stone-800 placeholder:text-stone-400 + dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-200 + dark:placeholder:text-neutral-500 + focus:outline-none focus:ring-2 focus:ring-primary-200" + /> +
+ ))} +
+
+ +
+
+ ); +} diff --git a/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx b/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx new file mode 100644 index 00000000000..bc8c32d60ef --- /dev/null +++ b/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx @@ -0,0 +1,322 @@ +/** + * Unit tests for MemorySourcesRegistry — All In button, gear/settings panel, + * per-kind field visibility, Save, and existing toggle behaviour. + */ +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as service from '../../../services/memorySourcesService'; +import type { MemorySourceEntry } from '../../../services/memorySourcesService'; +import { renderWithProviders } from '../../../test/test-utils'; +import { MemorySourcesRegistry } from '../MemorySourcesRegistry'; + +// Mock the entire service so we don't hit RPC +vi.mock('../../../services/memorySourcesService', async () => { + const actual = await vi.importActual( + '../../../services/memorySourcesService' + ); + return { + ...actual, + listMemorySources: vi.fn(), + memorySourcesStatusList: vi.fn(), + updateMemorySource: vi.fn(), + removeMemorySource: vi.fn(), + syncMemorySource: vi.fn(), + applyAllIn: vi.fn(), + }; +}); + +// Mock tauriCommands/memoryTree — not needed in these tests +vi.mock('../../../utils/tauriCommands/memoryTree', () => ({ + memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }), +})); + +const mockedList = vi.mocked(service.listMemorySources); +const mockedStatus = vi.mocked(service.memorySourcesStatusList); +const mockedUpdate = vi.mocked(service.updateMemorySource); +const mockedApplyAllIn = vi.mocked(service.applyAllIn); + +function makeSource(overrides: Partial = {}): MemorySourceEntry { + return { + id: 'src_1', + kind: 'github_repo', + label: 'My Repo', + enabled: true, + url: 'https://github.com/org/repo', + ...overrides, + }; +} + +function setup(sources: MemorySourceEntry[] = [makeSource()]) { + mockedList.mockResolvedValue(sources); + mockedStatus.mockResolvedValue([]); + const onToast = vi.fn(); + const result = renderWithProviders( + , + {} + ); + return { ...result, onToast }; +} + +describe('MemorySourcesRegistry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Basic render + // ------------------------------------------------------------------------- + it('renders loaded sources list', async () => { + setup([makeSource({ label: 'Work Repo' })]); + await screen.findByText('Work Repo'); + expect(screen.getByTestId('memory-sources')).toBeInTheDocument(); + }); + + it('renders empty state when no sources', async () => { + mockedList.mockResolvedValue([]); + mockedStatus.mockResolvedValue([]); + renderWithProviders(); + await screen.findByText(/no memory sources/i); + }); + + // ------------------------------------------------------------------------- + // Toggle (existing behaviour) + // ------------------------------------------------------------------------- + it('toggle calls updateMemorySource and flips state', async () => { + const source = makeSource({ enabled: true }); + mockedUpdate.mockResolvedValue({ ...source, enabled: false }); + setup([source]); + await screen.findByText('My Repo'); + + const toggle = screen.getByTitle(/disable/i); + fireEvent.click(toggle); + + await waitFor(() => { + expect(mockedUpdate).toHaveBeenCalledWith('src_1', { enabled: false }); + }); + }); + + // ------------------------------------------------------------------------- + // All In button + // ------------------------------------------------------------------------- + it('All In button is rendered in the header', async () => { + setup(); + await screen.findByText('My Repo'); + expect(screen.getByTestId('all-in-button')).toBeInTheDocument(); + }); + + it('clicking All In opens a confirmation modal', async () => { + setup(); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('all-in-button')); + + // The modal should appear + await screen.findByText('Go All In?'); + expect( + screen.getByText(/This enables every memory source and removes all sync limits/i) + ).toBeInTheDocument(); + }); + + it('cancelling All In modal closes it without calling applyAllIn', async () => { + setup(); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('all-in-button')); + await screen.findByText('Go All In?'); + + // Click the No / cancel button + fireEvent.click(screen.getByText('No')); + + await waitFor(() => { + expect(screen.queryByText('Go All In?')).not.toBeInTheDocument(); + }); + expect(mockedApplyAllIn).not.toHaveBeenCalled(); + }); + + it('confirming All In calls applyAllIn, updates sources, and shows success toast', async () => { + const updatedSrc = makeSource({ id: 'src_2', label: 'New Repo', enabled: true }); + mockedApplyAllIn.mockResolvedValue({ sources: [updatedSrc], sync_triggered: 1 }); + + const { onToast } = setup(); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('all-in-button')); + await screen.findByText('Go All In?'); + + fireEvent.click(screen.getByText('Yes')); + + await waitFor(() => { + expect(mockedApplyAllIn).toHaveBeenCalledOnce(); + }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('Go All In?')).not.toBeInTheDocument(); + }); + }); + + it('All In failure shows error toast', async () => { + mockedApplyAllIn.mockRejectedValue(new Error('RPC error')); + + const { onToast } = setup(); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('all-in-button')); + await screen.findByText('Go All In?'); + + fireEvent.click(screen.getByText('Yes')); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); + }); + + // ------------------------------------------------------------------------- + // Gear / settings panel — toggling + // ------------------------------------------------------------------------- + it('gear button renders for each source row', async () => { + setup([makeSource({ id: 'src_1' }), makeSource({ id: 'src_2', label: 'Second' })]); + await screen.findByText('My Repo'); + + expect(screen.getByTestId('memory-source-settings-src_1')).toBeInTheDocument(); + expect(screen.getByTestId('memory-source-settings-src_2')).toBeInTheDocument(); + }); + + it('clicking gear expands the settings panel for that source', async () => { + setup([makeSource({ id: 'src_1', kind: 'github_repo' })]); + await screen.findByText('My Repo'); + + expect(screen.queryByTestId('source-settings-panel-src_1')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + expect(screen.getByTestId('source-settings-panel-src_1')).toBeInTheDocument(); + }); + + it('clicking gear again collapses the settings panel', async () => { + setup([makeSource({ id: 'src_1', kind: 'github_repo' })]); + await screen.findByText('My Repo'); + + const gearBtn = screen.getByTestId('memory-source-settings-src_1'); + fireEvent.click(gearBtn); + expect(screen.getByTestId('source-settings-panel-src_1')).toBeInTheDocument(); + + fireEvent.click(gearBtn); + expect(screen.queryByTestId('source-settings-panel-src_1')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Settings panel — field visibility per kind + // ------------------------------------------------------------------------- + it('github_repo settings panel shows max_prs, max_issues, max_commits, sync_depth_days', async () => { + setup([makeSource({ id: 'src_1', kind: 'github_repo' })]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + const panel = screen.getByTestId('source-settings-panel-src_1'); + expect(within(panel).getByLabelText(/max pull requests/i)).toBeInTheDocument(); + expect(within(panel).getByLabelText(/max issues/i)).toBeInTheDocument(); + expect(within(panel).getByLabelText(/max commits/i)).toBeInTheDocument(); + expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument(); + }); + + it('composio settings panel shows sync_depth_days and max_items but NOT max_prs', async () => { + setup([makeSource({ id: 'src_1', kind: 'composio', toolkit: 'github' })]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + const panel = screen.getByTestId('source-settings-panel-src_1'); + expect(within(panel).getByLabelText(/max items/i)).toBeInTheDocument(); + expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument(); + expect(within(panel).queryByLabelText(/max pull requests/i)).not.toBeInTheDocument(); + }); + + it('rss_feed settings panel shows max_items and sync_depth_days but NOT max_prs', async () => { + setup([makeSource({ id: 'src_1', kind: 'rss_feed', url: 'https://example.com/feed.xml' })]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + const panel = screen.getByTestId('source-settings-panel-src_1'); + expect(within(panel).getByLabelText(/max items/i)).toBeInTheDocument(); + expect(within(panel).getByLabelText(/sync depth/i)).toBeInTheDocument(); + expect(within(panel).queryByLabelText(/max pull requests/i)).not.toBeInTheDocument(); + expect(within(panel).queryByLabelText(/max commits/i)).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Settings panel — Save + // ------------------------------------------------------------------------- + it('Save in settings panel calls updateMemorySource with numeric patch', async () => { + const source = makeSource({ id: 'src_1', kind: 'github_repo' }); + const updated = { ...source, max_prs: 50 }; + mockedUpdate.mockResolvedValue(updated); + + const { onToast } = setup([source]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + const panel = screen.getByTestId('source-settings-panel-src_1'); + const maxPrsInput = within(panel).getByLabelText(/max pull requests/i); + fireEvent.change(maxPrsInput, { target: { value: '50' } }); + + const saveBtn = within(panel).getByText('Save'); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(mockedUpdate).toHaveBeenCalledWith('src_1', expect.objectContaining({ max_prs: 50 })); + }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + }); + + it('empty input is omitted from the save patch (not sent as 0)', async () => { + const source = makeSource({ id: 'src_1', kind: 'github_repo', max_prs: 10 }); + mockedUpdate.mockResolvedValue(source); + + setup([source]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + + const panel = screen.getByTestId('source-settings-panel-src_1'); + const maxPrsInput = within(panel).getByLabelText(/max pull requests/i); + + // Clear the field + fireEvent.change(maxPrsInput, { target: { value: '' } }); + + fireEvent.click(within(panel).getByText('Save')); + + await waitFor(() => { + expect(mockedUpdate).toHaveBeenCalledWith( + 'src_1', + expect.not.objectContaining({ max_prs: expect.anything() }) + ); + }); + }); + + it('Save failure shows error toast', async () => { + mockedUpdate.mockRejectedValue(new Error('Save failed')); + + const { onToast } = setup([makeSource({ kind: 'github_repo' })]); + await screen.findByText('My Repo'); + + fireEvent.click(screen.getByTestId('memory-source-settings-src_1')); + const panel = screen.getByTestId('source-settings-panel-src_1'); + fireEvent.click(within(panel).getByText('Save')); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d7436f73ca8..858c4610dc2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1979,6 +1979,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'تم بناء الشجرة', 'memorySources.build.failedTitle': 'فشل البناء', 'memorySources.build.sealsMessage': 'تم إتمام عملية الختم', + 'memorySources.allIn.button': 'الكل', + 'memorySources.allIn.title': 'هل تريد تفعيل الكل؟', + 'memorySources.allIn.message': + 'سيؤدي هذا إلى تمكين جميع مصادر الذاكرة وإزالة جميع قيود المزامنة. يبني أغنى رسم بياني للذاكرة، لكنه قد يستهلك المزيد من الاعتمادات.', + 'memorySources.allIn.confirm': 'نعم', + 'memorySources.allIn.cancel': 'لا', + 'memorySources.allIn.success': 'تم تمكين جميع المصادر بدون قيود. بدأت المزامنة.', + 'memorySources.allIn.failed': 'تعذّر تطبيق خيار الكل. يرجى المحاولة مرة أخرى.', + 'memorySources.settings.button': 'الإعدادات', + 'memorySources.settings.title': 'إعدادات المزامنة', + 'memorySources.settings.maxPrs': 'أقصى عدد لطلبات السحب', + 'memorySources.settings.maxIssues': 'أقصى عدد للمشكلات', + 'memorySources.settings.maxCommits': 'أقصى عدد للإيداعات', + 'memorySources.settings.maxItems': 'أقصى عدد للعناصر', + 'memorySources.settings.sinceDays': 'فترة البحث (بالأيام)', + 'memorySources.settings.syncDepthDays': 'عمق المزامنة (بالأيام)', + 'memorySources.settings.maxTokens': 'أقصى عدد للرموز لكل مزامنة', + 'memorySources.settings.maxCost': 'الحد الأقصى للتكلفة لكل مزامنة (دولار)', + 'memorySources.settings.unlimitedHint': 'اتركه فارغًا للقيمة الافتراضية', + 'memorySources.settings.save': 'حفظ', + 'memorySources.settings.saving': 'جارٍ الحفظ…', + 'memorySources.settings.saved': 'تم حفظ الإعدادات', + 'memorySources.settings.saveFailed': 'تعذّر حفظ الإعدادات', 'backend.aiBackend': 'خلفية الذكاء الاصطناعي', 'backend.cloud': 'سحابي', 'backend.recommended': 'موصى به', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ed054313e02..91c381de458 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2018,6 +2018,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'ট্রি তৈরি হয়েছে', 'memorySources.build.failedTitle': 'তৈরি ব্যর্থ', 'memorySources.build.sealsMessage': 'সিল সম্পন্ন হয়েছে', + 'memorySources.allIn.button': 'সব চালু', + 'memorySources.allIn.title': 'সব সক্রিয় করবেন?', + 'memorySources.allIn.message': + 'এটি সমস্ত মেমরি উৎস সক্রিয় করবে এবং সিঙ্ক সীমা সরিয়ে দেবে। এটি সমৃদ্ধতম মেমরি গ্রাফ তৈরি করে, তবে আরও ক্রেডিট ব্যবহার করতে পারে।', + 'memorySources.allIn.confirm': 'হ্যাঁ', + 'memorySources.allIn.cancel': 'না', + 'memorySources.allIn.success': 'সব উৎস সীমা ছাড়া সক্রিয় হয়েছে। সিঙ্ক শুরু হয়েছে।', + 'memorySources.allIn.failed': 'সব চালু করা সম্ভব হয়নি। আবার চেষ্টা করুন।', + 'memorySources.settings.button': 'সেটিংস', + 'memorySources.settings.title': 'সিঙ্ক সেটিংস', + 'memorySources.settings.maxPrs': 'সর্বোচ্চ পুল রিকোয়েস্ট', + 'memorySources.settings.maxIssues': 'সর্বোচ্চ ইস্যু', + 'memorySources.settings.maxCommits': 'সর্বোচ্চ কমিট', + 'memorySources.settings.maxItems': 'সর্বোচ্চ আইটেম', + 'memorySources.settings.sinceDays': 'পূর্ববর্তী সময় (দিনে)', + 'memorySources.settings.syncDepthDays': 'সিঙ্ক গভীরতা (দিনে)', + 'memorySources.settings.maxTokens': 'প্রতি সিঙ্কে সর্বোচ্চ টোকেন', + 'memorySources.settings.maxCost': 'প্রতি সিঙ্কে সর্বোচ্চ খরচ (USD)', + 'memorySources.settings.unlimitedHint': 'ডিফল্টের জন্য ফাঁকা রাখুন', + 'memorySources.settings.save': 'সংরক্ষণ', + 'memorySources.settings.saving': 'সংরক্ষণ হচ্ছে…', + 'memorySources.settings.saved': 'সেটিংস সংরক্ষিত হয়েছে', + 'memorySources.settings.saveFailed': 'সেটিংস সংরক্ষণ করা সম্ভব হয়নি', 'backend.aiBackend': 'AI ব্যাকএন্ড', 'backend.cloud': 'ক্লাউড', 'backend.recommended': 'প্রস্তাবিত', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 85825be424a..9bbe4b355bb 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2069,6 +2069,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Baum erstellt', 'memorySources.build.failedTitle': 'Erstellung fehlgeschlagen', 'memorySources.build.sealsMessage': 'Versiegelung(en) abgeschlossen', + 'memorySources.allIn.button': 'Alles aktivieren', + 'memorySources.allIn.title': 'Alles aktivieren?', + 'memorySources.allIn.message': + 'Dadurch werden alle Speicherquellen aktiviert und alle Synchronisierungslimits entfernt. Es erstellt den reichhaltigsten Speichergraphen, kann aber mehr Credits verbrauchen.', + 'memorySources.allIn.confirm': 'Ja', + 'memorySources.allIn.cancel': 'Nein', + 'memorySources.allIn.success': 'Alle Quellen ohne Limits aktiviert. Synchronisierung gestartet.', + 'memorySources.allIn.failed': 'Konnte „Alles aktivieren" nicht anwenden. Bitte erneut versuchen.', + 'memorySources.settings.button': 'Einstellungen', + 'memorySources.settings.title': 'Synchronisierungseinstellungen', + 'memorySources.settings.maxPrs': 'Maximale Pull-Requests', + 'memorySources.settings.maxIssues': 'Maximale Issues', + 'memorySources.settings.maxCommits': 'Maximale Commits', + 'memorySources.settings.maxItems': 'Maximale Elemente', + 'memorySources.settings.sinceDays': 'Rückblick (Tage)', + 'memorySources.settings.syncDepthDays': 'Synchronisierungstiefe (Tage)', + 'memorySources.settings.maxTokens': 'Maximale Tokens pro Synchronisierung', + 'memorySources.settings.maxCost': 'Maximale Kosten pro Synchronisierung (USD)', + 'memorySources.settings.unlimitedHint': 'Leer lassen für Standardwert', + 'memorySources.settings.save': 'Speichern', + 'memorySources.settings.saving': 'Speichern…', + 'memorySources.settings.saved': 'Einstellungen gespeichert', + 'memorySources.settings.saveFailed': 'Einstellungen konnten nicht gespeichert werden', 'backend.aiBackend': 'KI-Backend', 'backend.cloud': 'Wolke', 'backend.recommended': 'Empfohlen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2851ecec3f6..2db26f75e30 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2348,6 +2348,29 @@ const en: TranslationMap = { 'memorySources.build.successTitle': 'Tree built', 'memorySources.build.failedTitle': 'Build failed', 'memorySources.build.sealsMessage': 'seal(s) completed', + 'memorySources.allIn.button': 'All In', + 'memorySources.allIn.title': 'Go All In?', + 'memorySources.allIn.message': + 'This enables every memory source and removes all sync limits. It builds the richest memory graph, but may use more credits.', + 'memorySources.allIn.confirm': 'Yes', + 'memorySources.allIn.cancel': 'No', + 'memorySources.allIn.success': 'All sources enabled with no limits. Syncing started.', + 'memorySources.allIn.failed': 'Could not apply All In. Please try again.', + 'memorySources.settings.button': 'Settings', + 'memorySources.settings.title': 'Sync settings', + 'memorySources.settings.maxPrs': 'Max pull requests', + 'memorySources.settings.maxIssues': 'Max issues', + 'memorySources.settings.maxCommits': 'Max commits', + 'memorySources.settings.maxItems': 'Max items', + 'memorySources.settings.sinceDays': 'Lookback (days)', + 'memorySources.settings.syncDepthDays': 'Sync depth (days)', + 'memorySources.settings.maxTokens': 'Max tokens per sync', + 'memorySources.settings.maxCost': 'Max cost per sync (USD)', + 'memorySources.settings.unlimitedHint': 'Leave blank for default', + 'memorySources.settings.save': 'Save', + 'memorySources.settings.saving': 'Saving…', + 'memorySources.settings.saved': 'Settings saved', + 'memorySources.settings.saveFailed': 'Could not save settings', // Backend 'backend.aiBackend': 'AI Backend', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0df0716e1ed..76060f41271 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2061,6 +2061,30 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Árbol construido', 'memorySources.build.failedTitle': 'Construcción fallida', 'memorySources.build.sealsMessage': 'sellado(s) completado(s)', + 'memorySources.allIn.button': 'Todo activo', + 'memorySources.allIn.title': '¿Activar todo?', + 'memorySources.allIn.message': + 'Esto activará todas las fuentes de memoria y eliminará todos los límites de sincronización. Construye el gráfico de memoria más rico, pero puede usar más créditos.', + 'memorySources.allIn.confirm': 'Sí', + 'memorySources.allIn.cancel': 'No', + 'memorySources.allIn.success': + 'Todas las fuentes activadas sin límites. Sincronización iniciada.', + 'memorySources.allIn.failed': 'No se pudo aplicar todo activo. Por favor, inténtelo de nuevo.', + 'memorySources.settings.button': 'Configuración', + 'memorySources.settings.title': 'Configuración de sincronización', + 'memorySources.settings.maxPrs': 'Máximo de pull requests', + 'memorySources.settings.maxIssues': 'Máximo de issues', + 'memorySources.settings.maxCommits': 'Máximo de commits', + 'memorySources.settings.maxItems': 'Máximo de elementos', + 'memorySources.settings.sinceDays': 'Período de búsqueda (días)', + 'memorySources.settings.syncDepthDays': 'Profundidad de sincronización (días)', + 'memorySources.settings.maxTokens': 'Máximo de tokens por sincronización', + 'memorySources.settings.maxCost': 'Costo máximo por sincronización (USD)', + 'memorySources.settings.unlimitedHint': 'Dejar en blanco para el valor predeterminado', + 'memorySources.settings.save': 'Guardar', + 'memorySources.settings.saving': 'Guardando…', + 'memorySources.settings.saved': 'Configuración guardada', + 'memorySources.settings.saveFailed': 'No se pudo guardar la configuración', 'backend.aiBackend': 'Backend de IA', 'backend.cloud': 'Nube', 'backend.recommended': 'Recomendado', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4d7a75bb0b2..254df970934 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2067,6 +2067,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Arbre construit', 'memorySources.build.failedTitle': 'Échec de construction', 'memorySources.build.sealsMessage': 'scellement(s) terminé(s)', + 'memorySources.allIn.button': 'Tout activer', + 'memorySources.allIn.title': 'Tout activer ?', + 'memorySources.allIn.message': + 'Cela activera toutes les sources de mémoire et supprimera toutes les limites de synchronisation. Cela construit le graphe de mémoire le plus riche, mais peut utiliser davantage de crédits.', + 'memorySources.allIn.confirm': 'Oui', + 'memorySources.allIn.cancel': 'Non', + 'memorySources.allIn.success': 'Toutes les sources activées sans limite. Synchronisation lancée.', + 'memorySources.allIn.failed': "Impossible d'appliquer tout activer. Veuillez réessayer.", + 'memorySources.settings.button': 'Paramètres', + 'memorySources.settings.title': 'Paramètres de synchronisation', + 'memorySources.settings.maxPrs': 'Nombre maximal de pull requests', + 'memorySources.settings.maxIssues': "Nombre maximal d'issues", + 'memorySources.settings.maxCommits': 'Nombre maximal de commits', + 'memorySources.settings.maxItems': "Nombre maximal d'éléments", + 'memorySources.settings.sinceDays': 'Période de recherche (jours)', + 'memorySources.settings.syncDepthDays': 'Profondeur de synchronisation (jours)', + 'memorySources.settings.maxTokens': 'Tokens maximum par synchronisation', + 'memorySources.settings.maxCost': 'Coût maximum par synchronisation (USD)', + 'memorySources.settings.unlimitedHint': 'Laisser vide pour la valeur par défaut', + 'memorySources.settings.save': 'Enregistrer', + 'memorySources.settings.saving': 'Enregistrement…', + 'memorySources.settings.saved': 'Paramètres enregistrés', + 'memorySources.settings.saveFailed': "Impossible d'enregistrer les paramètres", 'backend.aiBackend': 'Backend IA', 'backend.cloud': 'Cloud', 'backend.recommended': 'Recommandé', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 699bb949fb0..ce35045ac0b 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2019,6 +2019,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'ट्री बन गई', 'memorySources.build.failedTitle': 'निर्माण विफल', 'memorySources.build.sealsMessage': 'सील पूर्ण हुई', + 'memorySources.allIn.button': 'सब चालू', + 'memorySources.allIn.title': 'सब कुछ सक्रिय करें?', + 'memorySources.allIn.message': + 'यह सभी मेमोरी स्रोत सक्रिय करेगा और सभी सिंक सीमाएं हटा देगा। यह सबसे समृद्ध मेमोरी ग्राफ बनाता है, लेकिन अधिक क्रेडिट उपयोग कर सकता है।', + 'memorySources.allIn.confirm': 'हाँ', + 'memorySources.allIn.cancel': 'नहीं', + 'memorySources.allIn.success': 'सभी स्रोत बिना सीमा के सक्रिय हुए। सिंक शुरू हो गई।', + 'memorySources.allIn.failed': 'सब चालू नहीं किया जा सका। कृपया पुनः प्रयास करें।', + 'memorySources.settings.button': 'सेटिंग', + 'memorySources.settings.title': 'सिंक सेटिंग', + 'memorySources.settings.maxPrs': 'अधिकतम पुल रिक्वेस्ट', + 'memorySources.settings.maxIssues': 'अधिकतम इश्यू', + 'memorySources.settings.maxCommits': 'अधिकतम कमिट', + 'memorySources.settings.maxItems': 'अधिकतम आइटम', + 'memorySources.settings.sinceDays': 'पिछले दिन (दिनों में)', + 'memorySources.settings.syncDepthDays': 'सिंक गहराई (दिनों में)', + 'memorySources.settings.maxTokens': 'प्रति सिंक अधिकतम टोकन', + 'memorySources.settings.maxCost': 'प्रति सिंक अधिकतम लागत (USD)', + 'memorySources.settings.unlimitedHint': 'डिफ़ॉल्ट के लिए खाली छोड़ें', + 'memorySources.settings.save': 'सहेजें', + 'memorySources.settings.saving': 'सहेजा जा रहा है…', + 'memorySources.settings.saved': 'सेटिंग सहेजी गई', + 'memorySources.settings.saveFailed': 'सेटिंग सहेजी नहीं जा सकी', 'backend.aiBackend': 'AI बैकएंड', 'backend.cloud': 'क्लाउड', 'backend.recommended': 'सुझावित', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 22a1323370e..9099aac598f 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2022,6 +2022,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Pohon dibangun', 'memorySources.build.failedTitle': 'Pembangunan gagal', 'memorySources.build.sealsMessage': 'penyegelan selesai', + 'memorySources.allIn.button': 'Aktifkan Semua', + 'memorySources.allIn.title': 'Aktifkan Semua?', + 'memorySources.allIn.message': + 'Ini akan mengaktifkan semua sumber memori dan menghapus semua batas sinkronisasi. Membangun grafik memori terkaya, tetapi mungkin menggunakan lebih banyak kredit.', + 'memorySources.allIn.confirm': 'Ya', + 'memorySources.allIn.cancel': 'Tidak', + 'memorySources.allIn.success': 'Semua sumber diaktifkan tanpa batas. Sinkronisasi dimulai.', + 'memorySources.allIn.failed': 'Gagal mengaktifkan semua. Silakan coba lagi.', + 'memorySources.settings.button': 'Pengaturan', + 'memorySources.settings.title': 'Pengaturan sinkronisasi', + 'memorySources.settings.maxPrs': 'Maksimal pull request', + 'memorySources.settings.maxIssues': 'Maksimal isu', + 'memorySources.settings.maxCommits': 'Maksimal commit', + 'memorySources.settings.maxItems': 'Maksimal item', + 'memorySources.settings.sinceDays': 'Periode ke belakang (hari)', + 'memorySources.settings.syncDepthDays': 'Kedalaman sinkronisasi (hari)', + 'memorySources.settings.maxTokens': 'Maksimal token per sinkronisasi', + 'memorySources.settings.maxCost': 'Biaya maksimal per sinkronisasi (USD)', + 'memorySources.settings.unlimitedHint': 'Biarkan kosong untuk nilai default', + 'memorySources.settings.save': 'Simpan', + 'memorySources.settings.saving': 'Menyimpan…', + 'memorySources.settings.saved': 'Pengaturan tersimpan', + 'memorySources.settings.saveFailed': 'Gagal menyimpan pengaturan', 'backend.aiBackend': 'Backend AI', 'backend.cloud': 'Awan', 'backend.recommended': 'Direkomendasikan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 17c81649bc3..06191799203 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2051,6 +2051,30 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Albero costruito', 'memorySources.build.failedTitle': 'Costruzione fallita', 'memorySources.build.sealsMessage': 'sigillatura completata', + 'memorySources.allIn.button': 'Tutto attivo', + 'memorySources.allIn.title': 'Attivare tutto?', + 'memorySources.allIn.message': + 'Questo attiverà tutte le sorgenti di memoria e rimuoverà tutti i limiti di sincronizzazione. Costruisce il grafico di memoria più ricco, ma potrebbe utilizzare più crediti.', + 'memorySources.allIn.confirm': 'Sì', + 'memorySources.allIn.cancel': 'No', + 'memorySources.allIn.success': + 'Tutte le sorgenti attivate senza limiti. Sincronizzazione avviata.', + 'memorySources.allIn.failed': 'Impossibile attivare tutto. Riprova.', + 'memorySources.settings.button': 'Impostazioni', + 'memorySources.settings.title': 'Impostazioni di sincronizzazione', + 'memorySources.settings.maxPrs': 'Numero massimo di pull request', + 'memorySources.settings.maxIssues': 'Numero massimo di issue', + 'memorySources.settings.maxCommits': 'Numero massimo di commit', + 'memorySources.settings.maxItems': 'Numero massimo di elementi', + 'memorySources.settings.sinceDays': 'Periodo di ricerca (giorni)', + 'memorySources.settings.syncDepthDays': 'Profondità di sincronizzazione (giorni)', + 'memorySources.settings.maxTokens': 'Token massimi per sincronizzazione', + 'memorySources.settings.maxCost': 'Costo massimo per sincronizzazione (USD)', + 'memorySources.settings.unlimitedHint': 'Lasciare vuoto per il valore predefinito', + 'memorySources.settings.save': 'Salva', + 'memorySources.settings.saving': 'Salvataggio…', + 'memorySources.settings.saved': 'Impostazioni salvate', + 'memorySources.settings.saveFailed': 'Impossibile salvare le impostazioni', 'backend.aiBackend': 'Backend AI', 'backend.cloud': 'Cloud', 'backend.recommended': 'Consigliato', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6b2e7c0c3d4..3765aaee424 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1996,6 +1996,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': '트리 빌드 완료', 'memorySources.build.failedTitle': '빌드 실패', 'memorySources.build.sealsMessage': '실링 완료', + 'memorySources.allIn.button': '전체 활성화', + 'memorySources.allIn.title': '전체 활성화할까요?', + 'memorySources.allIn.message': + '모든 메모리 소스를 활성화하고 동기화 제한을 제거합니다. 가장 풍부한 메모리 그래프를 구축하지만 크레딧을 더 사용할 수 있습니다.', + 'memorySources.allIn.confirm': '예', + 'memorySources.allIn.cancel': '아니오', + 'memorySources.allIn.success': '제한 없이 모든 소스가 활성화되었습니다. 동기화가 시작되었습니다.', + 'memorySources.allIn.failed': '전체 활성화를 적용하지 못했습니다. 다시 시도해 주세요.', + 'memorySources.settings.button': '설정', + 'memorySources.settings.title': '동기화 설정', + 'memorySources.settings.maxPrs': '최대 풀 리퀘스트 수', + 'memorySources.settings.maxIssues': '최대 이슈 수', + 'memorySources.settings.maxCommits': '최대 커밋 수', + 'memorySources.settings.maxItems': '최대 항목 수', + 'memorySources.settings.sinceDays': '조회 기간 (일)', + 'memorySources.settings.syncDepthDays': '동기화 깊이 (일)', + 'memorySources.settings.maxTokens': '동기화당 최대 토큰', + 'memorySources.settings.maxCost': '동기화당 최대 비용 (USD)', + 'memorySources.settings.unlimitedHint': '기본값으로 설정하려면 비워 두세요', + 'memorySources.settings.save': '저장', + 'memorySources.settings.saving': '저장 중…', + 'memorySources.settings.saved': '설정이 저장되었습니다', + 'memorySources.settings.saveFailed': '설정을 저장하지 못했습니다', 'backend.aiBackend': 'AI 백엔드', 'backend.cloud': '클라우드', 'backend.recommended': '추천', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 4668bd66606..1e55509862d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2040,6 +2040,30 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Drzewo zbudowane', 'memorySources.build.failedTitle': 'Budowanie nie powiodło się', 'memorySources.build.sealsMessage': 'pieczętowanie zakończone', + 'memorySources.allIn.button': 'Włącz wszystko', + 'memorySources.allIn.title': 'Włączyć wszystko?', + 'memorySources.allIn.message': + 'Spowoduje to włączenie wszystkich źródeł pamięci i usunięcie wszystkich limitów synchronizacji. Buduje najbogatszy wykres pamięci, ale może zużywać więcej kredytów.', + 'memorySources.allIn.confirm': 'Tak', + 'memorySources.allIn.cancel': 'Nie', + 'memorySources.allIn.success': + 'Wszystkie źródła włączone bez limitów. Synchronizacja uruchomiona.', + 'memorySources.allIn.failed': 'Nie udało się włączyć wszystkiego. Spróbuj ponownie.', + 'memorySources.settings.button': 'Ustawienia', + 'memorySources.settings.title': 'Ustawienia synchronizacji', + 'memorySources.settings.maxPrs': 'Maksymalna liczba pull requestów', + 'memorySources.settings.maxIssues': 'Maksymalna liczba zgłoszeń', + 'memorySources.settings.maxCommits': 'Maksymalna liczba commitów', + 'memorySources.settings.maxItems': 'Maksymalna liczba elementów', + 'memorySources.settings.sinceDays': 'Okres wsteczny (dni)', + 'memorySources.settings.syncDepthDays': 'Głębokość synchronizacji (dni)', + 'memorySources.settings.maxTokens': 'Maksymalna liczba tokenów na synchronizację', + 'memorySources.settings.maxCost': 'Maksymalny koszt na synchronizację (USD)', + 'memorySources.settings.unlimitedHint': 'Pozostaw puste dla wartości domyślnej', + 'memorySources.settings.save': 'Zapisz', + 'memorySources.settings.saving': 'Zapisywanie…', + 'memorySources.settings.saved': 'Ustawienia zapisane', + 'memorySources.settings.saveFailed': 'Nie udało się zapisać ustawień', 'backend.aiBackend': 'Backend AI', 'backend.cloud': 'Chmura', 'backend.recommended': 'Zalecane', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index a306c4304f1..212015171d6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2059,6 +2059,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Árvore construída', 'memorySources.build.failedTitle': 'Falha na construção', 'memorySources.build.sealsMessage': 'selagem concluída', + 'memorySources.allIn.button': 'Ativar tudo', + 'memorySources.allIn.title': 'Ativar tudo?', + 'memorySources.allIn.message': + 'Isso ativará todas as fontes de memória e removerá todos os limites de sincronização. Cria o grafo de memória mais rico, mas pode usar mais créditos.', + 'memorySources.allIn.confirm': 'Sim', + 'memorySources.allIn.cancel': 'Não', + 'memorySources.allIn.success': 'Todas as fontes ativadas sem limites. Sincronização iniciada.', + 'memorySources.allIn.failed': 'Não foi possível ativar tudo. Por favor, tente novamente.', + 'memorySources.settings.button': 'Configurações', + 'memorySources.settings.title': 'Configurações de sincronização', + 'memorySources.settings.maxPrs': 'Máximo de pull requests', + 'memorySources.settings.maxIssues': 'Máximo de issues', + 'memorySources.settings.maxCommits': 'Máximo de commits', + 'memorySources.settings.maxItems': 'Máximo de itens', + 'memorySources.settings.sinceDays': 'Período de busca (dias)', + 'memorySources.settings.syncDepthDays': 'Profundidade de sincronização (dias)', + 'memorySources.settings.maxTokens': 'Máximo de tokens por sincronização', + 'memorySources.settings.maxCost': 'Custo máximo por sincronização (USD)', + 'memorySources.settings.unlimitedHint': 'Deixe em branco para o valor padrão', + 'memorySources.settings.save': 'Salvar', + 'memorySources.settings.saving': 'Salvando…', + 'memorySources.settings.saved': 'Configurações salvas', + 'memorySources.settings.saveFailed': 'Não foi possível salvar as configurações', 'backend.aiBackend': 'Backend de IA', 'backend.cloud': 'Nuvem', 'backend.recommended': 'Recomendado', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index d24a7c03de3..9533b78a345 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2032,6 +2032,31 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': 'Дерево построено', 'memorySources.build.failedTitle': 'Ошибка построения', 'memorySources.build.sealsMessage': 'запечатывание завершено', + 'memorySources.allIn.button': 'Включить всё', + 'memorySources.allIn.title': 'Включить всё?', + 'memorySources.allIn.message': + 'Это активирует все источники памяти и снимет все ограничения синхронизации. Создаёт наиболее богатый граф памяти, но может использовать больше кредитов.', + 'memorySources.allIn.confirm': 'Да', + 'memorySources.allIn.cancel': 'Нет', + 'memorySources.allIn.success': + 'Все источники активированы без ограничений. Синхронизация запущена.', + 'memorySources.allIn.failed': + 'Не удалось применить «Включить всё». Пожалуйста, попробуйте снова.', + 'memorySources.settings.button': 'Настройки', + 'memorySources.settings.title': 'Настройки синхронизации', + 'memorySources.settings.maxPrs': 'Максимум pull request', + 'memorySources.settings.maxIssues': 'Максимум задач', + 'memorySources.settings.maxCommits': 'Максимум коммитов', + 'memorySources.settings.maxItems': 'Максимум элементов', + 'memorySources.settings.sinceDays': 'Период (дней)', + 'memorySources.settings.syncDepthDays': 'Глубина синхронизации (дней)', + 'memorySources.settings.maxTokens': 'Максимум токенов за синхронизацию', + 'memorySources.settings.maxCost': 'Максимальная стоимость за синхронизацию (USD)', + 'memorySources.settings.unlimitedHint': 'Оставьте пустым для значения по умолчанию', + 'memorySources.settings.save': 'Сохранить', + 'memorySources.settings.saving': 'Сохранение…', + 'memorySources.settings.saved': 'Настройки сохранены', + 'memorySources.settings.saveFailed': 'Не удалось сохранить настройки', 'backend.aiBackend': 'AI-бэкенд', 'backend.cloud': 'Облако', 'backend.recommended': 'Рекомендуется', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index fa2c590ae2a..ca62f7a76b4 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1919,6 +1919,29 @@ const messages: TranslationMap = { 'memorySources.build.successTitle': '树构建完成', 'memorySources.build.failedTitle': '构建失败', 'memorySources.build.sealsMessage': '密封完成', + 'memorySources.allIn.button': '全部启用', + 'memorySources.allIn.title': '全部启用?', + 'memorySources.allIn.message': + '这将启用所有记忆来源并移除所有同步限制。可构建最丰富的记忆图谱,但可能会消耗更多积分。', + 'memorySources.allIn.confirm': '是', + 'memorySources.allIn.cancel': '否', + 'memorySources.allIn.success': '所有来源已无限制启用。同步已开始。', + 'memorySources.allIn.failed': '无法应用全部启用。请重试。', + 'memorySources.settings.button': '设置', + 'memorySources.settings.title': '同步设置', + 'memorySources.settings.maxPrs': '最大拉取请求数', + 'memorySources.settings.maxIssues': '最大问题数', + 'memorySources.settings.maxCommits': '最大提交数', + 'memorySources.settings.maxItems': '最大条目数', + 'memorySources.settings.sinceDays': '回溯天数', + 'memorySources.settings.syncDepthDays': '同步深度(天)', + 'memorySources.settings.maxTokens': '每次同步最大令牌数', + 'memorySources.settings.maxCost': '每次同步最大费用(USD)', + 'memorySources.settings.unlimitedHint': '留空使用默认值', + 'memorySources.settings.save': '保存', + 'memorySources.settings.saving': '保存中…', + 'memorySources.settings.saved': '设置已保存', + 'memorySources.settings.saveFailed': '无法保存设置', 'backend.aiBackend': 'AI 后端', 'backend.cloud': '云端', 'backend.recommended': '推荐', diff --git a/app/src/services/memorySourcesService.test.ts b/app/src/services/memorySourcesService.test.ts index 34300a3bb3a..cf2a499a1d7 100644 --- a/app/src/services/memorySourcesService.test.ts +++ b/app/src/services/memorySourcesService.test.ts @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { callCoreRpc } from './coreRpcClient'; import { addMemorySource, + applyAllIn, listMemorySources, + type MemorySourceEntry, removeMemorySource, SOURCE_KIND_ICONS, SOURCE_KIND_LABEL_KEYS, @@ -96,4 +98,73 @@ describe('memorySourcesService', () => { expect(SOURCE_KIND_ICONS[kind]).toBeTruthy(); } }); + + // --------------------------------------------------------------------------- + // applyAllIn + // --------------------------------------------------------------------------- + + it('applyAllIn calls the correct RPC method', async () => { + mockedCall.mockResolvedValue({ + result: { + sources: [{ id: 'src_1', kind: 'github_repo', label: 'All Repos', enabled: true }], + sync_triggered: 1, + }, + logs: [], + } as never); + + const result = await applyAllIn(); + + expect(mockedCall).toHaveBeenCalledWith({ method: 'openhuman.memory_sources_apply_all_in' }); + expect(result.sync_triggered).toBe(1); + expect(result.sources).toHaveLength(1); + expect(result.sources[0].id).toBe('src_1'); + }); + + it('applyAllIn handles envelope-wrapped response', async () => { + mockedCall.mockResolvedValue({ result: { sources: [], sync_triggered: 0 }, logs: [] } as never); + + const result = await applyAllIn(); + expect(result.sources).toEqual([]); + expect(result.sync_triggered).toBe(0); + }); + + it('applyAllIn handles flat (un-wrapped) response', async () => { + mockedCall.mockResolvedValue({ + sources: [{ id: 'src_2', kind: 'folder', label: 'Docs', enabled: true }], + sync_triggered: 1, + } as never); + + const result = await applyAllIn(); + expect(result.sources).toHaveLength(1); + }); + + // --------------------------------------------------------------------------- + // MemorySourceEntry interface includes all limit fields + // --------------------------------------------------------------------------- + + it('MemorySourceEntry interface accepts all limit fields at compile-time', () => { + // This test is a type-level assertion: if the fields are missing from the + // interface the assignment below would fail TypeScript compilation. + const entry: MemorySourceEntry = { + id: 'src_1', + kind: 'github_repo', + label: 'Test', + enabled: true, + max_commits: 100, + max_issues: 200, + max_prs: 50, + max_items: 500, + since_days: 30, + sync_depth_days: 90, + max_tokens_per_sync: 100_000, + max_cost_per_sync_usd: 1.5, + }; + expect(entry.max_commits).toBe(100); + expect(entry.max_issues).toBe(200); + expect(entry.max_prs).toBe(50); + expect(entry.since_days).toBe(30); + expect(entry.sync_depth_days).toBe(90); + expect(entry.max_tokens_per_sync).toBe(100_000); + expect(entry.max_cost_per_sync_usd).toBe(1.5); + }); }); diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts index 7fe9291d930..f954320fe6b 100644 --- a/app/src/services/memorySourcesService.ts +++ b/app/src/services/memorySourcesService.ts @@ -31,9 +31,16 @@ export interface MemorySourceEntry { branch?: string; paths?: string[]; query?: string; + selector?: string; + // Sync limit fields (all optional; omit = use backend default / unlimited) since_days?: number; max_items?: number; - selector?: string; + max_commits?: number; + max_issues?: number; + max_prs?: number; + sync_depth_days?: number; + max_tokens_per_sync?: number; + max_cost_per_sync_usd?: number; } export interface SourceItem { @@ -159,6 +166,25 @@ export async function syncMemorySource(sourceId: string): Promise { }); } +export interface ApplyAllInResult { + sources: MemorySourceEntry[]; + sync_triggered: number; +} + +/** + * Enables every memory source, clears all per-source sync caps, and + * triggers a background sync for each. Equivalent to the UI "All In" + * action. Maps to `openhuman.memory_sources_apply_all_in`. + */ +export async function applyAllIn(): Promise { + log('apply_all_in'); + const resp = await callCoreRpc({ + method: 'openhuman.memory_sources_apply_all_in', + }); + const data = unwrap(resp); + return { sources: data.sources ?? [], sync_triggered: data.sync_triggered ?? 0 }; +} + /// 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/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index 35b416613d7..d66d99a2767 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -451,6 +451,8 @@ async fn main() -> Result<()> { toolkit: conn.toolkit.clone(), connection_id: Some(conn.id.clone()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; match run_backfill_via_search(&ctx, cli.days).await { Ok(outcome) => { @@ -549,6 +551,8 @@ async fn main() -> Result<()> { toolkit: conn.toolkit.clone(), connection_id: Some(conn.id.clone()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; match provider.sync(&ctx, SyncReason::Manual).await { Ok(outcome) => { diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index fd1209f00ab..05a04594bc1 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -1264,6 +1264,8 @@ pub async fn composio_get_user_profile( toolkit: toolkit.clone(), connection_id: Some(connection_id.to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let profile = provider.fetch_user_profile(&ctx).await.map_err(|e| { @@ -1336,6 +1338,8 @@ pub async fn composio_refresh_all_identities( toolkit: toolkit.clone(), connection_id: Some(connection_id.clone()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; match provider.fetch_user_profile(&ctx).await { @@ -1433,6 +1437,8 @@ pub async fn composio_sync( toolkit: toolkit.clone(), connection_id: Some(connection_id.to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let started_at_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 0badcd8a866..1c0e5b81d9a 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -406,6 +406,15 @@ pub struct Config { #[serde(default)] pub model_registry: Vec, + + /// One-time migration guard: set to `true` after + /// `apply_composio_source_caps_migration` has run successfully. + /// + /// Defaults to `false` (`#[serde(default)]`) on existing installs so the + /// migration fires on the first launch that includes this code, then never + /// again. This is a "has-run" sentinel — no functional default value needed. + #[serde(default)] + pub composio_source_caps_migrated: bool, } /// Shared default so `#[serde(default)]` and `Config::default()` stay in sync. @@ -724,6 +733,7 @@ impl Default for Config { onboarding_completed: false, chat_onboarding_completed: false, model_registry: Vec::new(), + composio_source_caps_migrated: false, } } } diff --git a/src/openhuman/memory_sources/mod.rs b/src/openhuman/memory_sources/mod.rs index 565d0917cee..7d0ee57956e 100644 --- a/src/openhuman/memory_sources/mod.rs +++ b/src/openhuman/memory_sources/mod.rs @@ -25,10 +25,11 @@ pub mod sync; pub mod types; pub use registry::{ - add_source, get_source, list_enabled_by_kind, list_sources, - remove_composio_source_by_connection_id, remove_source, update_source, upsert_composio_source, - MemorySourcePatch, + add_source, apply_all_in, composio_defaults_for_toolkit, get_source, list_enabled_by_kind, + list_sources, remove_composio_source_by_connection_id, remove_source, update_source, + upsert_composio_source, MemorySourcePatch, }; +pub use rpc::apply_kind_defaults; pub use schemas::{ all_controller_schemas as all_memory_sources_controller_schemas, all_registered_controllers as all_memory_sources_registered_controllers, diff --git a/src/openhuman/memory_sources/reconcile.rs b/src/openhuman/memory_sources/reconcile.rs index 26689774fb0..c5639ae9112 100644 --- a/src/openhuman/memory_sources/reconcile.rs +++ b/src/openhuman/memory_sources/reconcile.rs @@ -3,9 +3,14 @@ //! Called once at boot to ensure all active Composio sync targets have //! a corresponding `MemorySourceEntry` in config. This catches connections //! created before the memory_sources domain existed. +//! +//! Also owns the one-time retroactive migration +//! (`apply_composio_source_caps_migration`) that enables previously-disabled +//! Composio sources with conservative per-toolkit caps. use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::memory_sources::registry; +use crate::openhuman::memory_sources::types::SourceKind; use crate::openhuman::memory_sync::composio; pub async fn ensure_composio_sources() { @@ -67,6 +72,80 @@ pub async fn ensure_composio_sources() { "[memory_sources:reconcile] composio reconciliation complete" ); } + + // Run the one-time caps migration after the reconcile loop so any + // sources upserted just above are also considered. + if let Err(e) = apply_composio_source_caps_migration().await { + tracing::warn!( + error = %e, + "[memory_sources:reconcile] caps migration failed (non-fatal, will retry next time)" + ); + } +} + +/// One-time retroactive migration: flip previously-disabled Composio sources +/// that have no caps to `enabled = true` with conservative per-toolkit defaults. +/// +/// Guarded by `Config.composio_source_caps_migrated` — runs at most once per +/// install. Idempotent within a single run: only processes entries where BOTH +/// `enabled == false` AND `max_items.is_none() && sync_depth_days.is_none()`. +/// Entries the user has already customised (non-None caps) are left untouched. +pub async fn apply_composio_source_caps_migration() -> Result<(), String> { + let mut config = config_rpc::load_config_with_timeout().await?; + + if config.composio_source_caps_migrated { + tracing::debug!("[memory_sources:reconcile] caps migration already applied; skipping"); + return Ok(()); + } + + tracing::info!("[memory_sources:reconcile] applying composio source caps migration"); + + let mut migrated_count = 0u32; + + for source in &mut config.memory_sources { + match source.kind { + SourceKind::Composio => { + // Only migrate entries that are disabled AND have no caps set + // (i.e. the user has not customised them). + if !source.enabled && source.max_items.is_none() && source.sync_depth_days.is_none() + { + let toolkit = source.toolkit.as_deref().unwrap_or(""); + let (max_items, sync_depth_days) = + registry::composio_defaults_for_toolkit(toolkit); + tracing::debug!( + id = %source.id, + toolkit = %toolkit, + max_items = ?max_items, + sync_depth_days = ?sync_depth_days, + "[memory_sources:reconcile] caps migration: enabling disabled source with defaults" + ); + source.enabled = true; + source.max_items = max_items; + source.sync_depth_days = sync_depth_days; + migrated_count += 1; + } + } + // Apply non-composio kind defaults for entries with all-None caps. + _ => { + // Use the rpc::apply_kind_defaults helper so the same + // conservative values are applied consistently. + crate::openhuman::memory_sources::rpc::apply_kind_defaults(source); + } + } + } + + config.composio_source_caps_migrated = true; + config + .save() + .await + .map_err(|e| format!("caps migration: failed to save config: {e:#}"))?; + + tracing::info!( + migrated = migrated_count, + "[memory_sources:reconcile] caps migration complete" + ); + + Ok(()) } fn title_case(s: &str) -> String { @@ -92,6 +171,123 @@ fn short_id(id: &str) -> &str { #[cfg(test)] mod tests { use super::*; + use crate::openhuman::memory_sources::types::{MemorySourceEntry, SourceKind}; + + fn make_composio_entry( + id: &str, + toolkit: &str, + enabled: bool, + max_items: Option, + sync_depth_days: Option, + ) -> MemorySourceEntry { + MemorySourceEntry { + id: id.to_string(), + kind: SourceKind::Composio, + label: toolkit.to_string(), + enabled, + toolkit: Some(toolkit.to_string()), + connection_id: Some(format!("conn_{id}")), + path: None, + glob: None, + url: None, + branch: None, + paths: Vec::new(), + max_commits: None, + max_issues: None, + max_prs: None, + query: None, + since_days: None, + max_items, + selector: None, + max_tokens_per_sync: None, + max_cost_per_sync_usd: None, + sync_depth_days, + } + } + + /// Simulate the migration logic applied to a Vec of sources so we can + /// test the transformation without touching the global config. + fn run_migration_on_entries(sources: &mut Vec) -> u32 { + let mut migrated = 0u32; + for source in sources.iter_mut() { + if source.kind == SourceKind::Composio + && !source.enabled + && source.max_items.is_none() + && source.sync_depth_days.is_none() + { + let toolkit = source.toolkit.as_deref().unwrap_or(""); + let (max_items, sync_depth_days) = registry::composio_defaults_for_toolkit(toolkit); + source.enabled = true; + source.max_items = max_items; + source.sync_depth_days = sync_depth_days; + migrated += 1; + } + } + migrated + } + + #[test] + fn migration_flips_disabled_capless_entry_to_enabled_with_caps() { + let mut sources = vec![make_composio_entry("s1", "gmail", false, None, None)]; + let count = run_migration_on_entries(&mut sources); + assert_eq!(count, 1); + assert!(sources[0].enabled); + assert_eq!(sources[0].max_items, Some(100)); + assert_eq!(sources[0].sync_depth_days, Some(30)); + } + + #[test] + fn migration_leaves_already_enabled_entry_untouched() { + let mut sources = vec![make_composio_entry("s2", "slack", true, None, None)]; + let count = run_migration_on_entries(&mut sources); + // Already enabled — migration predicate requires enabled == false. + assert_eq!(count, 0); + assert!(sources[0].enabled); + // Caps should still be None (we didn't touch it). + assert_eq!(sources[0].max_items, None); + } + + #[test] + fn migration_leaves_user_customised_caps_untouched() { + // User set max_items explicitly → migration should not override. + let mut sources = vec![make_composio_entry("s3", "notion", false, Some(5), None)]; + let count = run_migration_on_entries(&mut sources); + assert_eq!(count, 0, "entry with user-set caps must not be migrated"); + assert!(!sources[0].enabled, "enabled must not be flipped"); + assert_eq!(sources[0].max_items, Some(5), "user cap must be preserved"); + } + + #[test] + fn migration_is_noop_on_empty_list() { + let mut sources: Vec = vec![]; + let count = run_migration_on_entries(&mut sources); + assert_eq!(count, 0); + } + + #[test] + fn migration_applies_correct_defaults_per_toolkit() { + let toolkits = [ + ("gmail", Some(100u32), Some(30u32)), + ("slack", Some(50), Some(14)), + ("notion", Some(30), Some(30)), + ("linear", Some(50), Some(30)), + ("clickup", Some(50), Some(30)), + ("github", Some(50), Some(30)), + ("unknown", Some(30), Some(14)), + ]; + for (toolkit, exp_items, exp_days) in &toolkits { + let mut sources = vec![make_composio_entry("sid", toolkit, false, None, None)]; + run_migration_on_entries(&mut sources); + assert_eq!( + sources[0].max_items, *exp_items, + "max_items mismatch for toolkit={toolkit}" + ); + assert_eq!( + sources[0].sync_depth_days, *exp_days, + "sync_depth_days mismatch for toolkit={toolkit}" + ); + } + } #[test] fn short_id_truncates_ascii() { diff --git a/src/openhuman/memory_sources/registry.rs b/src/openhuman/memory_sources/registry.rs index bbfdedf28b0..a6c6cd5e8c4 100644 --- a/src/openhuman/memory_sources/registry.rs +++ b/src/openhuman/memory_sources/registry.rs @@ -7,6 +7,24 @@ use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::memory_sources::types::{MemorySourceEntry, SourceKind}; +/// Conservative default caps applied to new Composio source entries +/// at upsert time (insert-only — the existing-entry update branch +/// never overwrites user-set caps). +/// +/// Returns `(max_items, sync_depth_days)`. +pub fn composio_defaults_for_toolkit(toolkit: &str) -> (Option, Option) { + match toolkit { + "gmail" => (Some(100), Some(30)), + "slack" => (Some(50), Some(14)), + "notion" => (Some(30), Some(30)), + "linear" => (Some(50), Some(30)), + "clickup" => (Some(50), Some(30)), + "github" => (Some(50), Some(30)), + // Generic fallback for any toolkit not listed above. + _ => (Some(30), Some(14)), + } +} + pub async fn list_sources() -> Result, String> { let config = config_rpc::load_config_with_timeout().await?; Ok(config.memory_sources.clone()) @@ -111,6 +129,15 @@ pub async fn update_source( if let Some(v) = patch.sync_depth_days { entry.sync_depth_days = Some(v); } + if let Some(v) = patch.max_commits { + entry.max_commits = Some(v); + } + if let Some(v) = patch.max_issues { + entry.max_issues = Some(v); + } + if let Some(v) = patch.max_prs { + entry.max_prs = Some(v); + } entry.validate()?; let updated = entry.clone(); @@ -202,11 +229,19 @@ pub async fn upsert_composio_source( return Ok(updated); } + let (default_max_items, default_sync_depth_days) = composio_defaults_for_toolkit(toolkit); + tracing::debug!( + toolkit = %toolkit, + max_items = ?default_max_items, + sync_depth_days = ?default_sync_depth_days, + "[memory_sources] applying conservative defaults for new composio source" + ); + let entry = MemorySourceEntry { id: format!("src_{}", uuid::Uuid::new_v4().as_simple()), kind: SourceKind::Composio, label: label.to_string(), - enabled: false, + enabled: true, toolkit: Some(toolkit.to_string()), connection_id: Some(connection_id.to_string()), path: None, @@ -219,11 +254,11 @@ pub async fn upsert_composio_source( max_prs: None, query: None, since_days: None, - max_items: None, + max_items: default_max_items, selector: None, max_tokens_per_sync: None, max_cost_per_sync_usd: None, - sync_depth_days: None, + sync_depth_days: default_sync_depth_days, }; config.memory_sources.push(entry.clone()); config @@ -275,12 +310,104 @@ pub struct MemorySourcePatch { pub max_cost_per_sync_usd: Option, #[serde(default)] pub sync_depth_days: Option, + // ── GithubRepo-specific caps (previously missing from patch) ── + #[serde(default)] + pub max_commits: Option, + #[serde(default)] + pub max_issues: Option, + #[serde(default)] + pub max_prs: Option, +} + +/// Enable ALL configured memory sources and clear every per-source cap, +/// giving the user unrestricted access ("All In" mode). +/// +/// For each source in `config.memory_sources`: +/// - Sets `enabled = true`. +/// - Clears `max_items`, `since_days`, `sync_depth_days`, +/// `max_commits`, `max_issues`, `max_prs`, +/// `max_tokens_per_sync`, `max_cost_per_sync_usd` to `None`. +/// +/// Saves config once after all mutations and returns the updated entries. +pub async fn apply_all_in() -> Result, String> { + let mut config = config_rpc::load_config_with_timeout().await?; + + tracing::info!( + count = config.memory_sources.len(), + "[memory_sources] apply_all_in: enabling all sources and clearing caps" + ); + + for source in &mut config.memory_sources { + tracing::debug!( + id = %source.id, + kind = %source.kind.as_str(), + label = %source.label, + "[memory_sources] apply_all_in: enabling source and clearing caps" + ); + source.enabled = true; + source.max_items = None; + source.since_days = None; + source.sync_depth_days = None; + source.max_commits = None; + source.max_issues = None; + source.max_prs = None; + source.max_tokens_per_sync = None; + source.max_cost_per_sync_usd = None; + } + + let updated = config.memory_sources.clone(); + + config + .save() + .await + .map_err(|e| format!("apply_all_in: failed to save config: {e:#}"))?; + + tracing::info!( + count = updated.len(), + "[memory_sources] apply_all_in: complete — all sources enabled, all caps cleared" + ); + + Ok(updated) } #[cfg(test)] mod tests { use super::*; + #[test] + fn composio_defaults_for_known_toolkits() { + assert_eq!( + composio_defaults_for_toolkit("gmail"), + (Some(100), Some(30)) + ); + assert_eq!(composio_defaults_for_toolkit("slack"), (Some(50), Some(14))); + assert_eq!( + composio_defaults_for_toolkit("notion"), + (Some(30), Some(30)) + ); + assert_eq!( + composio_defaults_for_toolkit("linear"), + (Some(50), Some(30)) + ); + assert_eq!( + composio_defaults_for_toolkit("clickup"), + (Some(50), Some(30)) + ); + assert_eq!( + composio_defaults_for_toolkit("github"), + (Some(50), Some(30)) + ); + } + + #[test] + fn composio_defaults_for_generic_fallback() { + assert_eq!( + composio_defaults_for_toolkit("unknown_toolkit_xyz"), + (Some(30), Some(14)) + ); + assert_eq!(composio_defaults_for_toolkit(""), (Some(30), Some(14))); + } + #[test] fn memory_source_patch_deserializes_partial() { let json = serde_json::json!({ "label": "New label", "enabled": false }); @@ -289,4 +416,29 @@ mod tests { assert_eq!(patch.enabled, Some(false)); assert!(patch.toolkit.is_none()); } + + #[test] + fn memory_source_patch_round_trips_github_limit_fields() { + let json = serde_json::json!({ + "max_commits": 100, + "max_issues": 50, + "max_prs": 25 + }); + let patch: MemorySourcePatch = serde_json::from_value(json).unwrap(); + assert_eq!(patch.max_commits, Some(100)); + assert_eq!(patch.max_issues, Some(50)); + assert_eq!(patch.max_prs, Some(25)); + // Unset fields must be None (serde(default)) + assert!(patch.label.is_none()); + assert!(patch.enabled.is_none()); + } + + #[test] + fn memory_source_patch_defaults_github_fields_to_none() { + let json = serde_json::json!({ "enabled": true }); + let patch: MemorySourcePatch = serde_json::from_value(json).unwrap(); + assert!(patch.max_commits.is_none()); + assert!(patch.max_issues.is_none()); + assert!(patch.max_prs.is_none()); + } } diff --git a/src/openhuman/memory_sources/rpc.rs b/src/openhuman/memory_sources/rpc.rs index 367b87de0b7..431f5eca801 100644 --- a/src/openhuman/memory_sources/rpc.rs +++ b/src/openhuman/memory_sources/rpc.rs @@ -105,7 +105,7 @@ pub async fn add_rpc(req: AddRequest) -> Result, String> "[memory_sources] add_rpc: entry" ); - let entry = MemorySourceEntry { + let mut entry = MemorySourceEntry { id: format!("src_{}", uuid::Uuid::new_v4().as_simple()), kind: req.kind, label: req.label, @@ -117,9 +117,6 @@ pub async fn add_rpc(req: AddRequest) -> Result, String> url: req.url, branch: req.branch, paths: req.paths, - // Per-type GitHub sync caps default to DEFAULT_GITHUB_ITEM_LIMIT - // (2000) in the reader when None. Overrides are applied via the - // update/patch path (`MemorySourcePatch`). max_commits: req.max_commits, max_issues: req.max_issues, max_prs: req.max_prs, @@ -132,10 +129,48 @@ pub async fn add_rpc(req: AddRequest) -> Result, String> sync_depth_days: req.sync_depth_days, }; + // Apply conservative per-kind defaults when the caller left caps unset. + apply_kind_defaults(&mut entry); + let source = registry::add_source(entry).await?; Ok(RpcOutcome::new(AddResponse { source }, vec![])) } +/// Apply conservative per-kind cap defaults to a new source entry. +/// +/// Only fills fields that are still `None` — never overwrites a +/// caller-supplied value. This mirrors the retroactive migration logic in +/// `reconcile::apply_composio_source_caps_migration` so the same defaults +/// are applied consistently at creation time and during migration. +pub fn apply_kind_defaults(entry: &mut MemorySourceEntry) { + match entry.kind { + SourceKind::GithubRepo => { + if entry.max_prs.is_none() { + entry.max_prs = Some(10); + } + if entry.max_issues.is_none() { + entry.max_issues = Some(10); + } + if entry.max_commits.is_none() { + entry.max_commits = Some(50); + } + } + SourceKind::RssFeed => { + if entry.max_items.is_none() { + entry.max_items = Some(20); + } + } + SourceKind::TwitterQuery => { + if entry.since_days.is_none() { + entry.since_days = Some(7); + } + } + // Folder / WebPage / Composio: no defaults to apply here. + // Composio defaults are set at upsert time in registry::upsert_composio_source. + _ => {} + } +} + // ── Update ── #[derive(Debug, serde::Deserialize)] @@ -394,3 +429,72 @@ pub async fn monthly_cost_summary_rpc() -> Result, + /// Number of sync tasks spawned (one per enabled source). + pub sync_triggered: u32, +} + +/// Enable ALL memory sources, clear all caps, and trigger a sync for +/// every source. +/// +/// Returns immediately with the updated source list and the number of +/// syncs queued. Individual syncs run in the background and publish +/// `MemorySyncStageChanged` events as they progress. +pub async fn apply_all_in_rpc() -> Result, String> { + tracing::info!("[memory_sources] apply_all_in_rpc: entry"); + + // Enable all sources and clear caps. + let sources = registry::apply_all_in().await?; + + // Trigger a background sync for every enabled source. + let config = config_rpc::load_config_with_timeout().await?; + let mut sync_triggered: u32 = 0; + + for source in &sources { + if !source.enabled { + continue; + } + tracing::debug!( + source_id = %source.id, + kind = %source.kind.as_str(), + "[memory_sources] apply_all_in_rpc: triggering sync" + ); + match crate::openhuman::memory_sources::sync::sync_source(source.clone(), config.clone()) + .await + { + Ok(()) => { + sync_triggered += 1; + } + Err(e) => { + // Non-fatal: log and continue — best-effort sync trigger. + tracing::warn!( + source_id = %source.id, + error = %e, + "[memory_sources] apply_all_in_rpc: sync trigger failed for source" + ); + } + } + } + + tracing::info!( + sources = sources.len(), + sync_triggered, + "[memory_sources] apply_all_in_rpc: complete" + ); + + Ok(RpcOutcome::new( + AllInResponse { + sources, + sync_triggered, + }, + vec![], + )) +} diff --git a/src/openhuman/memory_sources/schemas.rs b/src/openhuman/memory_sources/schemas.rs index 7184e2c457e..4e5403275b4 100644 --- a/src/openhuman/memory_sources/schemas.rs +++ b/src/openhuman/memory_sources/schemas.rs @@ -55,6 +55,24 @@ fn kind_specific_fields() -> Vec { comment: "Path filters for github_repo sources.", required: false, }, + FieldSchema { + name: "max_commits", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max commits per sync for github_repo sources.", + required: false, + }, + FieldSchema { + name: "max_issues", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max issues per sync for github_repo sources.", + required: false, + }, + FieldSchema { + name: "max_prs", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max pull requests per sync for github_repo sources.", + required: false, + }, FieldSchema { name: "query", ty: TypeSchema::Option(Box::new(TypeSchema::String)), @@ -70,7 +88,7 @@ fn kind_specific_fields() -> Vec { FieldSchema { name: "max_items", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Maximum items for rss_feed sources.", + comment: "Maximum items for rss_feed or composio sources.", required: false, }, FieldSchema { @@ -114,6 +132,7 @@ pub fn all_controller_schemas() -> Vec { schemas("sync_audit_log"), schemas("estimate_sync_cost"), schemas("monthly_cost_summary"), + schemas("apply_all_in"), ] } @@ -167,6 +186,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("monthly_cost_summary"), handler: handle_monthly_cost_summary, }, + RegisteredController { + schema: schemas("apply_all_in"), + handler: handle_apply_all_in, + }, ] } @@ -487,6 +510,29 @@ pub fn schemas(function: &str) -> ControllerSchema { }, ], }, + "apply_all_in" => ControllerSchema { + namespace: NAMESPACE, + function: "apply_all_in", + description: "Enable ALL memory sources, clear all per-source caps, \ + and trigger a background sync for every source. \ + Returns immediately with the updated source list and \ + the count of sync tasks queued.", + inputs: vec![], + outputs: vec![ + FieldSchema { + name: "sources", + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("MemorySourceEntry"))), + comment: "All memory sources after the all-in transformation.", + required: true, + }, + FieldSchema { + name: "sync_triggered", + ty: TypeSchema::U64, + comment: "Number of sync tasks spawned.", + required: true, + }, + ], + }, other => panic!("unknown memory_sources schema function: {other}"), } } @@ -563,6 +609,10 @@ fn handle_monthly_cost_summary(_params: Map) -> ControllerFuture Box::pin(async move { to_json(rpc::monthly_cost_summary_rpc().await?) }) } +fn handle_apply_all_in(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(rpc::apply_all_in_rpc().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_sync/composio/bus.rs b/src/openhuman/memory_sync/composio/bus.rs index ee1f8417693..cbdc2f0150d 100644 --- a/src/openhuman/memory_sync/composio/bus.rs +++ b/src/openhuman/memory_sync/composio/bus.rs @@ -470,7 +470,26 @@ impl EventHandler for ComposioConnectionCreatedSubscriber { return; } }; - let Some(ctx) = ProviderContext::from_config( + // Look up per-source caps from the memory_sources registry. + // Non-fatal: if the lookup fails we proceed without caps. + // Note: upsert_composio_source runs after this block, so the + // entry may not exist yet for brand-new connections. In that + // case (None, None) is the right default — the source will be + // created with defaults by the upsert below. + let (src_max_items, src_sync_depth_days) = { + let registry_sources = crate::openhuman::memory_sources::list_enabled_by_kind( + crate::openhuman::memory_sources::SourceKind::Composio, + ) + .await + .unwrap_or_default(); + registry_sources + .iter() + .find(|s| s.connection_id.as_deref() == Some(connection_id.as_str())) + .map(|s| (s.max_items, s.sync_depth_days)) + .unwrap_or((None, None)) + }; + + let Some(mut ctx) = ProviderContext::from_config( Arc::new(config), toolkit.clone(), Some(connection_id.clone()), @@ -482,6 +501,17 @@ impl EventHandler for ComposioConnectionCreatedSubscriber { return; }; + ctx.max_items = src_max_items; + ctx.sync_depth_days = src_sync_depth_days; + + tracing::debug!( + toolkit = %toolkit, + connection_id = %connection_id, + max_items = ?src_max_items, + sync_depth_days = ?src_sync_depth_days, + "[composio:bus] caps from registry for connection_created" + ); + // `wait_for_connection_active` is a backend-only metadata // probe (`list_connections`). Resolve a backend // `ComposioClient` from the live config for it; direct-mode diff --git a/src/openhuman/memory_sync/composio/mod.rs b/src/openhuman/memory_sync/composio/mod.rs index 565fade10e5..6e20dcad832 100644 --- a/src/openhuman/memory_sync/composio/mod.rs +++ b/src/openhuman/memory_sync/composio/mod.rs @@ -151,11 +151,35 @@ pub async fn run_connection_sync( )) })?; + // Look up the source entry to obtain any user-configured caps. + // Non-fatal: if the registry read fails we proceed uncapped. + let (src_max_items, src_sync_depth_days) = { + let registry_sources = crate::openhuman::memory_sources::list_enabled_by_kind( + crate::openhuman::memory_sources::SourceKind::Composio, + ) + .await + .unwrap_or_default(); + registry_sources + .iter() + .find(|s| s.connection_id.as_deref() == Some(&target.connection_id)) + .map(|s| (s.max_items, s.sync_depth_days)) + .unwrap_or((None, None)) + }; + + tracing::debug!( + connection_id = %target.connection_id, + max_items = ?src_max_items, + sync_depth_days = ?src_sync_depth_days, + "[composio:sync] run_connection_sync: caps from registry" + ); + let ctx = ProviderContext { config: std::sync::Arc::new(config), toolkit: target.toolkit, connection_id: Some(target.connection_id), usage: Default::default(), + max_items: src_max_items, + sync_depth_days: src_sync_depth_days, }; let sync_result = provider.sync(&ctx, reason).await; diff --git a/src/openhuman/memory_sync/composio/periodic.rs b/src/openhuman/memory_sync/composio/periodic.rs index b9c3f7aea9e..04a0ccd4f31 100644 --- a/src/openhuman/memory_sync/composio/periodic.rs +++ b/src/openhuman/memory_sync/composio/periodic.rs @@ -312,6 +312,29 @@ pub(crate) async fn run_one_tick() -> Result<(), String> { continue; } + // Look up per-source caps from the memory_sources registry. + // Non-fatal: if the lookup fails we proceed without caps. + let (src_max_items, src_sync_depth_days) = { + let registry_sources = crate::openhuman::memory_sources::list_enabled_by_kind( + crate::openhuman::memory_sources::SourceKind::Composio, + ) + .await + .unwrap_or_default(); + registry_sources + .iter() + .find(|s| s.connection_id.as_deref() == Some(conn.id.as_str())) + .map(|s| (s.max_items, s.sync_depth_days)) + .unwrap_or((None, None)) + }; + + tracing::debug!( + toolkit = %toolkit, + connection_id = %conn.id, + max_items = ?src_max_items, + sync_depth_days = ?src_sync_depth_days, + "[composio:periodic] caps from registry" + ); + // Build a context tied to this specific connection and dispatch. // `ProviderContext` no longer caches a pre-baked // `ComposioClient` — provider methods resolve a fresh handle per @@ -322,6 +345,8 @@ pub(crate) async fn run_one_tick() -> Result<(), String> { toolkit: toolkit.clone(), connection_id: Some(conn.id.clone()), usage: Default::default(), + max_items: src_max_items, + sync_depth_days: src_sync_depth_days, }; tracing::debug!( diff --git a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs index 75d7e9a795f..b5f3d40c951 100644 --- a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs @@ -254,12 +254,42 @@ impl ComposioProvider for ClickUpProvider { _ => PAGE_SIZE, }; + // ctx.max_items: cap max pages per workspace. + let effective_max_pages = if let Some(cap) = ctx.max_items { + let pages_for_cap = super::super::helpers::pages_for_max_items(cap, page_size); + let effective = MAX_PAGES_PER_WORKSPACE.min(pages_for_cap); + if effective < MAX_PAGES_PER_WORKSPACE { + tracing::debug!( + connection_id = %connection_id, + max_items = cap, + effective_max_pages = effective, + "[composio:clickup] [memory_sync] applying max_items page cap" + ); + } + effective + } else { + MAX_PAGES_PER_WORKSPACE + }; + + // ctx.sync_depth_days: oldest allowed date_updated for client-side skip. + let oldest_allowed_time: Option = ctx.sync_depth_days.map(|days| { + let floor = chrono::Utc::now() - chrono::Duration::days(days as i64); + let s = floor.to_rfc3339(); + tracing::debug!( + connection_id = %connection_id, + sync_depth_days = days, + oldest_allowed = %s, + "[composio:clickup] [memory_sync] applying sync_depth_days floor" + ); + s + }); + let mut total_fetched: usize = 0; let mut total_persisted: usize = 0; let mut newest_updated: Option = None; 'workspaces: for workspace_id in &workspaces { - for page_num in 0..MAX_PAGES_PER_WORKSPACE { + for page_num in 0..effective_max_pages { if state.budget_exhausted() { tracing::info!( workspace_id = %workspace_id, @@ -350,6 +380,20 @@ impl ComposioProvider for ClickUpProvider { None => task_id.clone(), }; + // ctx.sync_depth_days: skip tasks older than the depth floor. + if let (Some(ref floor), Some(ref ts)) = (&oldest_allowed_time, &updated) { + if ts < floor { + tracing::debug!( + task_id = %task_id, + date_updated = %ts, + floor = %floor, + "[composio:clickup] [memory_sync] skipping task older than sync_depth_days" + ); + hit_cursor_boundary = true; + continue; + } + } + // If `date_updated` is at or older than our cursor // *and* we've already synced this composite key, the // rest of the page is by definition older too — we @@ -403,6 +447,20 @@ impl ComposioProvider for ClickUpProvider { break; } + // ctx.max_items hard stop. + if let Some(cap) = ctx.max_items { + if total_persisted >= cap as usize { + tracing::debug!( + workspace_id = %workspace_id, + page = page_num, + total_persisted, + max_items = cap, + "[composio:clickup] [memory_sync] max_items reached, stopping pagination" + ); + break 'workspaces; + } + } + // ClickUp's filtered-team-tasks endpoint signals the last // page implicitly: when fewer than `page_size` results // come back, there are no more pages. diff --git a/src/openhuman/memory_sync/composio/providers/github/provider.rs b/src/openhuman/memory_sync/composio/providers/github/provider.rs index 134d26fc8c8..bb053132b81 100644 --- a/src/openhuman/memory_sync/composio/providers/github/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/github/provider.rs @@ -195,8 +195,46 @@ impl ComposioProvider for GitHubProvider { _ => PAGE_SIZE, }; - // Build the base search query. - let query = build_search_query(&login, state.cursor.as_deref()); + // ctx.max_items: cap MAX_PAGES. + let effective_max_pages = if let Some(cap) = ctx.max_items { + let pages_for_cap = super::super::helpers::pages_for_max_items(cap, page_size); + let effective = MAX_PAGES.min(pages_for_cap); + if effective < MAX_PAGES { + tracing::debug!( + connection_id = %connection_id, + max_items = cap, + effective_max_pages = effective, + "[composio:github] [memory_sync] applying max_items page cap" + ); + } + effective + } else { + MAX_PAGES + }; + + // ctx.sync_depth_days: inject `updated:>{date}` on first sync when set. + let depth_query_fragment: Option = if state.cursor.is_none() { + ctx.sync_depth_days.map(|days| { + let floor = chrono::Utc::now() - chrono::Duration::days(days as i64); + let s = floor.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + tracing::debug!( + connection_id = %connection_id, + sync_depth_days = days, + floor = %s, + "[composio:github] [memory_sync] injecting updated:> date filter on first sync" + ); + format!("updated:>{s}") + }) + } else { + None + }; + + // Build the base search query — include depth fragment when set. + let query = build_search_query_with_depth( + &login, + state.cursor.as_deref(), + depth_query_fragment.as_deref(), + ); let mut total_fetched: usize = 0; let mut total_persisted: usize = 0; @@ -210,7 +248,7 @@ impl ComposioProvider for GitHubProvider { // `is_synced` on the re-fetch, so the cost of holding is minimal. let mut had_ingest_failures = false; - 'pages: for page_num in 1..=MAX_PAGES { + 'pages: for page_num in 1..=effective_max_pages { if state.budget_exhausted() { tracing::info!( page = page_num, @@ -342,6 +380,19 @@ impl ComposioProvider for GitHubProvider { } } + // ctx.max_items hard stop. + if let Some(cap) = ctx.max_items { + if total_persisted >= cap as usize { + tracing::debug!( + page = page_num, + total_persisted, + max_items = cap, + "[composio:github] [memory_sync] max_items reached, stopping pagination" + ); + break; + } + } + // GitHub search pages are 0-indexed in terms of total results; // a short page means we've exhausted the result set. if (issues.len() as u32) < page_size { @@ -590,3 +641,48 @@ pub(super) fn build_search_query(login: &str, cursor: Option<&str>) -> String { None => format!("involves:{login}"), } } + +/// Extended variant that optionally appends a `sync_depth_days` fragment on +/// first sync (no cursor). The `depth_fragment` is expected to be a pre-built +/// `"updated:>{date}"` string. +pub(super) fn build_search_query_with_depth( + login: &str, + cursor: Option<&str>, + depth_fragment: Option<&str>, +) -> String { + match cursor { + Some(c) => format!("involves:{login} updated:>{c}"), + None => match depth_fragment { + Some(fragment) => format!("involves:{login} {fragment}"), + None => format!("involves:{login}"), + }, + } +} + +#[cfg(test)] +mod depth_tests { + use super::*; + + #[test] + fn build_search_query_with_depth_no_cursor_no_depth() { + let q = build_search_query_with_depth("alice", None, None); + assert_eq!(q, "involves:alice"); + } + + #[test] + fn build_search_query_with_depth_no_cursor_with_depth() { + let q = build_search_query_with_depth("alice", None, Some("updated:>2024-01-01T00:00:00Z")); + assert_eq!(q, "involves:alice updated:>2024-01-01T00:00:00Z"); + } + + #[test] + fn build_search_query_with_depth_cursor_wins_over_depth() { + // When cursor is set, depth fragment is ignored. + let q = build_search_query_with_depth( + "alice", + Some("2024-06-01T00:00:00Z"), + Some("updated:>2024-01-01T00:00:00Z"), + ); + assert_eq!(q, "involves:alice updated:>2024-06-01T00:00:00Z"); + } +} diff --git a/src/openhuman/memory_sync/composio/providers/gmail/provider.rs b/src/openhuman/memory_sync/composio/providers/gmail/provider.rs index 67f353d34d5..728eb67a094 100644 --- a/src/openhuman/memory_sync/composio/providers/gmail/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/gmail/provider.rs @@ -258,7 +258,7 @@ impl ComposioProvider for GmailProvider { // legitimately want the larger ceiling — and the cap only // kicks in when we have a prior `last_sync_at_ms` to compare // against, so first-ever syncs are unaffected. - let max_pages = match reason { + let base_max_pages = match reason { SyncReason::ConnectionCreated => MAX_PAGES_PER_SYNC, _ => match state.last_sync_at_ms { Some(last_ms) if sync::now_ms().saturating_sub(last_ms) < RECENT_SYNC_WINDOW_MS => { @@ -274,6 +274,41 @@ impl ComposioProvider for GmailProvider { }, }; + // ctx.max_items: reduce max_pages so we never fetch more than the cap. + let max_pages = if let Some(cap) = ctx.max_items { + let pages_for_cap = pages_for_max_items(cap, page_size); + let effective = base_max_pages.min(pages_for_cap); + if effective < base_max_pages { + tracing::debug!( + connection_id = %connection_id, + max_items = cap, + page_size, + pages_for_cap, + effective_max_pages = effective, + "[composio:gmail] [memory_sync] applying max_items page cap from source config" + ); + } + effective + } else { + base_max_pages + }; + + // ctx.sync_depth_days: on first sync (no cursor), add an after: floor. + let depth_floor_filter: Option = if state.cursor.is_none() { + ctx.sync_depth_days.map(|days| { + let floor_secs = epoch_floor_from_depth(days); + tracing::debug!( + connection_id = %connection_id, + sync_depth_days = days, + floor_epoch_secs = floor_secs, + "[composio:gmail] [memory_sync] applying sync_depth_days floor on first sync" + ); + floor_secs.to_string() + }) + } else { + None + }; + let mut total_fetched: usize = 0; let mut total_persisted: usize = 0; let mut total_requests: u32 = 0; @@ -321,6 +356,9 @@ impl ComposioProvider for GmailProvider { "[composio:gmail] using day-level filter from cursor (epoch parse failed)" ); } + } else if let Some(ref floor) = depth_floor_filter { + // First sync with sync_depth_days: apply the epoch floor. + query.push_str(&format!(" after:{floor}")); } let mut args = json!({ @@ -512,6 +550,20 @@ impl ComposioProvider for GmailProvider { break; } + // ctx.max_items hard stop: break once the per-source cap is reached. + if let Some(cap) = ctx.max_items { + if total_persisted >= cap as usize { + tracing::debug!( + page = page_num, + total_persisted, + max_items = cap, + "[composio:gmail] [memory_sync] max_items reached, stopping pagination" + ); + stop_reason = "max_items"; + break; + } + } + // Check for next page token. page_token = sync::extract_page_token(&resp.data); if page_token.is_none() { @@ -616,3 +668,57 @@ impl ComposioProvider for GmailProvider { Ok(()) } } + +/// Compute the number of pages needed to cover `max_items` at `page_size` +/// items per page, rounding up. Used to cap `MAX_PAGES_PER_SYNC` by the +/// per-source `max_items` limit. +/// +/// Returns `u32::MAX` when `page_size == 0` to avoid division by zero — +/// callers should treat this as "no page cap". +pub(super) fn pages_for_max_items(max_items: u32, page_size: u32) -> u32 { + if page_size == 0 { + return u32::MAX; + } + (max_items + page_size - 1) / page_size +} + +/// Compute the Unix epoch timestamp (seconds) for `sync_depth_days` days ago. +/// Used to build the `after:` Gmail search filter on first sync. +pub(super) fn epoch_floor_from_depth(sync_depth_days: u32) -> i64 { + let now = chrono::Utc::now(); + let floor = now - chrono::Duration::days(sync_depth_days as i64); + floor.timestamp() +} + +#[cfg(test)] +mod cap_tests { + use super::*; + + #[test] + fn pages_for_max_items_rounds_up() { + assert_eq!(pages_for_max_items(100, 25), 4); + assert_eq!(pages_for_max_items(101, 25), 5); + assert_eq!(pages_for_max_items(1, 25), 1); + assert_eq!(pages_for_max_items(50, 50), 1); + assert_eq!(pages_for_max_items(51, 50), 2); + } + + #[test] + fn pages_for_max_items_zero_page_size() { + assert_eq!(pages_for_max_items(100, 0), u32::MAX); + } + + #[test] + fn epoch_floor_from_depth_is_in_the_past() { + let floor = epoch_floor_from_depth(30); + let now = chrono::Utc::now().timestamp(); + // Floor must be in the past. + assert!(floor < now); + // Floor must be approximately 30 days ago (within 1 day tolerance). + let diff_days = (now - floor) / 86400; + assert!( + diff_days >= 29 && diff_days <= 31, + "expected ~30 days in past, got {diff_days}" + ); + } +} diff --git a/src/openhuman/memory_sync/composio/providers/helpers.rs b/src/openhuman/memory_sync/composio/providers/helpers.rs index 8d24328029c..139a6cb8914 100644 --- a/src/openhuman/memory_sync/composio/providers/helpers.rs +++ b/src/openhuman/memory_sync/composio/providers/helpers.rs @@ -46,6 +46,60 @@ pub(crate) fn merge_extra(args: &mut serde_json::Value, extra: &serde_json::Valu } } +// ── Cap enforcement helpers ────────────────────────────────────────────── + +/// Compute the number of API pages needed to cover `max_items` at `page_size` +/// items per page, rounding up. +/// +/// Returns `u32::MAX` when `page_size == 0` to avoid division by zero; +/// callers should treat this as "no page cap beyond the provider's own upper +/// bound". +pub(crate) fn pages_for_max_items(max_items: u32, page_size: u32) -> u32 { + if page_size == 0 { + return u32::MAX; + } + (max_items + page_size - 1) / page_size +} + +/// Compute the Unix epoch timestamp (seconds) for `sync_depth_days` days ago. +/// Used to build after-date filters (e.g. Gmail `after:`) on first sync. +pub(crate) fn epoch_floor_from_depth(sync_depth_days: u32) -> i64 { + let now = chrono::Utc::now(); + let floor = now - chrono::Duration::days(sync_depth_days as i64); + floor.timestamp() +} + +#[cfg(test)] +mod cap_helper_tests { + use super::*; + + #[test] + fn pages_for_max_items_rounds_up() { + assert_eq!(pages_for_max_items(100, 25), 4); + assert_eq!(pages_for_max_items(101, 25), 5); + assert_eq!(pages_for_max_items(1, 25), 1); + assert_eq!(pages_for_max_items(50, 50), 1); + assert_eq!(pages_for_max_items(51, 50), 2); + } + + #[test] + fn pages_for_max_items_zero_page_size() { + assert_eq!(pages_for_max_items(100, 0), u32::MAX); + } + + #[test] + fn epoch_floor_from_depth_is_in_the_past() { + let floor = epoch_floor_from_depth(30); + let now = chrono::Utc::now().timestamp(); + assert!(floor < now); + let diff_days = (now - floor) / 86400; + assert!( + diff_days >= 29 && diff_days <= 31, + "expected ~30 days in past, got {diff_days}" + ); + } +} + /// Resolve the first array found among `array_paths` (dotted object /// paths), then return the first non-empty string at one of `fields` /// on that array's first element. Complements [`pick_str`], which diff --git a/src/openhuman/memory_sync/composio/providers/linear/provider.rs b/src/openhuman/memory_sync/composio/providers/linear/provider.rs index 51ac5ff918d..abf87a4c5d1 100644 --- a/src/openhuman/memory_sync/composio/providers/linear/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/linear/provider.rs @@ -191,6 +191,36 @@ impl ComposioProvider for LinearProvider { _ => PAGE_SIZE, }; + // ctx.max_items: cap max pages. + let effective_max_pages = if let Some(cap) = ctx.max_items { + let pages_for_cap = super::super::helpers::pages_for_max_items(cap, page_size as u32); + let effective = MAX_PAGES_PER_SYNC.min(pages_for_cap); + if effective < MAX_PAGES_PER_SYNC { + tracing::debug!( + connection_id = %connection_id, + max_items = cap, + effective_max_pages = effective, + "[composio:linear] [memory_sync] applying max_items page cap" + ); + } + effective + } else { + MAX_PAGES_PER_SYNC + }; + + // ctx.sync_depth_days: oldest allowed updatedAt for client-side skip. + let oldest_allowed_time: Option = ctx.sync_depth_days.map(|days| { + let floor = chrono::Utc::now() - chrono::Duration::days(days as i64); + let s = floor.to_rfc3339(); + tracing::debug!( + connection_id = %connection_id, + sync_depth_days = days, + oldest_allowed = %s, + "[composio:linear] [memory_sync] applying sync_depth_days floor" + ); + s + }); + let mut total_fetched: usize = 0; let mut total_persisted: usize = 0; let mut had_persist_failures = false; @@ -198,7 +228,7 @@ impl ComposioProvider for LinearProvider { let mut after_cursor: Option = None; let mut hit_cursor_boundary = false; - for page_num in 0..MAX_PAGES_PER_SYNC { + for page_num in 0..effective_max_pages { if state.budget_exhausted() { tracing::info!( page = page_num, @@ -271,6 +301,20 @@ impl ComposioProvider for LinearProvider { None => issue_id.clone(), }; + // ctx.sync_depth_days: skip issues older than the depth floor. + if let (Some(ref floor), Some(ref ts)) = (&oldest_allowed_time, &updated) { + if ts < floor { + tracing::debug!( + issue_id = %issue_id, + updated_at = %ts, + floor = %floor, + "[composio:linear] [memory_sync] skipping issue older than sync_depth_days" + ); + hit_cursor_boundary = true; + continue; + } + } + // If `updatedAt` is at or older than our cursor *and* // we already synced this key, the rest of the page is // by definition older — stop early. @@ -322,6 +366,19 @@ impl ComposioProvider for LinearProvider { break; } + // ctx.max_items hard stop. + if let Some(cap) = ctx.max_items { + if total_persisted >= cap as usize { + tracing::debug!( + page = page_num, + total_persisted, + max_items = cap, + "[composio:linear] [memory_sync] max_items reached, stopping pagination" + ); + break; + } + } + // Advance to the next page using Linear's cursor-based pagination. match sync::extract_pagination_cursor(&resp.data) { Some(next_cursor) => { diff --git a/src/openhuman/memory_sync/composio/providers/notion/provider.rs b/src/openhuman/memory_sync/composio/providers/notion/provider.rs index 58ea6fea52d..f7e9d4172d7 100644 --- a/src/openhuman/memory_sync/composio/providers/notion/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/notion/provider.rs @@ -178,6 +178,39 @@ impl ComposioProvider for NotionProvider { _ => PAGE_SIZE, }; + // ctx.max_items: cap the number of pages. Keep existing MAX_PAGES_PER_SYNC + // as the upper bound; ctx caps only ever reduce it. + let effective_max_pages = if let Some(cap) = ctx.max_items { + let pages_for_cap = super::super::helpers::pages_for_max_items(cap, page_size); + let effective = MAX_PAGES_PER_SYNC.min(pages_for_cap); + if effective < MAX_PAGES_PER_SYNC { + tracing::debug!( + connection_id = %connection_id, + max_items = cap, + pages_for_cap, + effective_max_pages = effective, + "[composio:notion] [memory_sync] applying max_items page cap" + ); + } + effective + } else { + MAX_PAGES_PER_SYNC + }; + + // ctx.sync_depth_days: compute the oldest allowed edited_time string + // for client-side skipping of stale results. + let oldest_allowed_time: Option = ctx.sync_depth_days.map(|days| { + let floor = chrono::Utc::now() - chrono::Duration::days(days as i64); + let s = floor.to_rfc3339(); + tracing::debug!( + connection_id = %connection_id, + sync_depth_days = days, + oldest_allowed = %s, + "[composio:notion] [memory_sync] applying sync_depth_days floor" + ); + s + }); + let mut total_fetched: usize = 0; let mut total_persisted: usize = 0; let mut newest_edited_time: Option = None; @@ -191,7 +224,7 @@ impl ComposioProvider for NotionProvider { // `is_synced` on the re-fetch, so the cost of holding is minimal. let mut had_ingest_failures = false; - for page_num in 0..MAX_PAGES_PER_SYNC { + for page_num in 0..effective_max_pages { if state.budget_exhausted() { tracing::info!( page = page_num, @@ -269,6 +302,22 @@ impl ComposioProvider for NotionProvider { None => page_id.clone(), }; + // ctx.sync_depth_days: skip pages edited before the depth floor. + if let (Some(ref floor), Some(ref et)) = (&oldest_allowed_time, &edited_time) { + if et < floor { + tracing::debug!( + page_id = %page_id, + edited_time = %et, + floor = %floor, + "[composio:notion] [memory_sync] skipping page older than sync_depth_days" + ); + // Pages are sorted descending by last_edited_time so + // once we see one below the floor the rest are too. + hit_cursor_boundary = true; + continue; + } + } + // If the page's edited time is older than our cursor, // we've caught up — everything beyond is already synced. if let (Some(ref cursor), Some(ref et)) = (&state.cursor, &edited_time) { @@ -327,6 +376,19 @@ impl ComposioProvider for NotionProvider { break; } + // ctx.max_items hard stop. + if let Some(cap) = ctx.max_items { + if total_persisted >= cap as usize { + tracing::debug!( + page = page_num, + total_persisted, + max_items = cap, + "[composio:notion] [memory_sync] max_items reached, stopping pagination" + ); + break; + } + } + // Check for next page cursor from Notion API. notion_cursor = sync::extract_notion_cursor(&resp.data); if notion_cursor.is_none() { diff --git a/src/openhuman/memory_sync/composio/providers/slack/provider.rs b/src/openhuman/memory_sync/composio/providers/slack/provider.rs index f2f0d7784f0..f477afdef51 100644 --- a/src/openhuman/memory_sync/composio/providers/slack/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/slack/provider.rs @@ -513,6 +513,19 @@ impl ComposioProvider for SlackProvider { "[composio:slack] state save failed after channel (non-fatal)" ); } + + // ctx.max_items hard stop across all channels. + if let Some(cap) = ctx.max_items { + if total_messages_ingested >= cap as usize { + tracing::debug!( + connection_id = %connection_id, + total_messages_ingested, + max_items = cap, + "[composio:slack] [memory_sync] max_items reached, stopping channel iteration" + ); + break; + } + } } let finished_at_ms = sync::now_ms(); @@ -622,8 +635,20 @@ async fn process_channel( // with full precision, so multi-message-per-second channels don't // replay the whole second on the next incremental fetch. When no // cursor exists yet, fall back to `.000000`. + // ctx.sync_depth_days wins over the env-var OPENHUMAN_SLACK_BACKFILL_DAYS + // default when set — it comes from the user-configured source entry. let oldest_ts = cursors.get(&channel.id).cloned().unwrap_or_else(|| { - let secs = (now - chrono::Duration::days(backfill_days())).timestamp(); + let depth_days = ctx + .sync_depth_days + .map(|d| d as i64) + .unwrap_or_else(backfill_days); + let secs = (now - chrono::Duration::days(depth_days)).timestamp(); + tracing::debug!( + channel = %channel.id, + depth_days, + oldest_ts_secs = secs, + "[composio:slack] [memory_sync] computing oldest_ts for backfill" + ); format!("{secs}.000000") }); diff --git a/src/openhuman/memory_sync/composio/providers/slack/rpc.rs b/src/openhuman/memory_sync/composio/providers/slack/rpc.rs index f0d9a147bd4..9d4fe23f21a 100644 --- a/src/openhuman/memory_sync/composio/providers/slack/rpc.rs +++ b/src/openhuman/memory_sync/composio/providers/slack/rpc.rs @@ -111,6 +111,8 @@ pub async fn sync_trigger_rpc( toolkit: conn.toolkit.clone(), connection_id: Some(conn.id.clone()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; match provider.sync(&ctx, SyncReason::Manual).await { Ok(o) => outcomes.push(o), diff --git a/src/openhuman/memory_sync/composio/providers/types.rs b/src/openhuman/memory_sync/composio/providers/types.rs index 86ca8379e55..b1980a1538c 100644 --- a/src/openhuman/memory_sync/composio/providers/types.rs +++ b/src/openhuman/memory_sync/composio/providers/types.rs @@ -228,6 +228,18 @@ pub struct ProviderContext { /// (`run_connection_sync`) reads it back. Non-sync callers (agent tools, /// task-source fetches) leave it at zero — harmless. pub usage: ComposioUsageHandle, + /// Maximum items to fetch in a single sync pass. + /// + /// Set from the corresponding `MemorySourceEntry.max_items` field at + /// sync-dispatch time. `None` means no cap beyond the provider's own + /// internal upper bounds. + pub max_items: Option, + /// Maximum sync depth window in days. + /// + /// Set from `MemorySourceEntry.sync_depth_days`. When `Some(n)`, the + /// provider only fetches items from the last `n` days. `None` means + /// no additional depth restriction beyond the provider's cursor. + pub sync_depth_days: Option, } impl ProviderContext { @@ -258,6 +270,8 @@ impl ProviderContext { toolkit: toolkit.into(), connection_id, usage: ComposioUsageHandle::default(), + max_items: None, + sync_depth_days: None, }), Err(e) => { tracing::debug!( @@ -420,6 +434,8 @@ mod tests { toolkit: "gmail".to_string(), connection_id: None, usage: ComposioUsageHandle::default(), + max_items: None, + sync_depth_days: None, }; let cloned = ctx.clone(); @@ -475,6 +491,8 @@ mod tests { toolkit: "gmail".to_string(), connection_id: None, usage: ComposioUsageHandle::default(), + max_items: None, + sync_depth_days: None, }; let res = ctx.execute("GMAIL_FETCH_EMAILS", None).await; // The actual HTTP call will fail in the unit-test sandbox, but @@ -508,6 +526,8 @@ mod tests { toolkit: "gmail".to_string(), connection_id: None, usage: ComposioUsageHandle::default(), + max_items: None, + sync_depth_days: None, }; let res = ctx.execute("GMAIL_FETCH_EMAILS", None).await; let err = res.expect_err("no backend session must error"); diff --git a/src/openhuman/task_sources/ops.rs b/src/openhuman/task_sources/ops.rs index 67a8f5f07c3..76db025a818 100644 --- a/src/openhuman/task_sources/ops.rs +++ b/src/openhuman/task_sources/ops.rs @@ -153,6 +153,8 @@ pub async fn preview_filter( toolkit: provider.as_str().to_string(), connection_id: connection_id.filter(|s| !s.trim().is_empty()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let max = max.unwrap_or(config.task_sources.max_tasks_per_fetch); let fetch_filter = filter::to_fetch_filter(&filter_spec, max); diff --git a/src/openhuman/task_sources/pipeline.rs b/src/openhuman/task_sources/pipeline.rs index 9094bdc59ff..74953f1e2e4 100644 --- a/src/openhuman/task_sources/pipeline.rs +++ b/src/openhuman/task_sources/pipeline.rs @@ -102,6 +102,8 @@ async fn run_inner( toolkit: source.provider.as_str().to_string(), connection_id: source.connection_id.clone(), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let fetch_filter = filter::to_fetch_filter(&source.filter, source.max_tasks_per_fetch); From d9a3700b9c03019f10d046e1f51d2aeb2480d519 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Wed, 3 Jun 2026 19:01:12 +0530 Subject: [PATCH 02/11] docs(about_app): add memory source sync controls capability Catalog the new default-on sync + per-source settings + All In feature as intelligence.memory_source_sync_controls per the capability catalog rule. --- src/openhuman/about_app/catalog_data.rs | 19 +++++++++++++++++++ src/openhuman/about_app/catalog_tests.rs | 1 + 2 files changed, 20 insertions(+) diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 9c116e9ba78..8e553f82970 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -355,6 +355,25 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: GITHUB_REPO_SOURCE, }, + Capability { + id: "intelligence.memory_source_sync_controls", + name: "Memory Source Sync Defaults & Controls", + domain: "memory_sources", + category: CapabilityCategory::Intelligence, + description: "Connected memory sources are enabled by default with conservative, \ + per-kind sync caps so the first sync stays cheap (e.g. Gmail ~100 recent emails, \ + GitHub repo 10 PRs / 10 issues / 50 commits, RSS 20 items). Each source row exposes \ + an inline settings panel to adjust the limit fields that apply to its kind \ + (max_items, sync_depth_days, max_prs/issues/commits, since_days, token/cost budgets). \ + An \"All In\" action enables every source and removes the caps to build the richest \ + memory graph, then triggers a full sync. Already-connected sources are migrated to \ + the new defaults once.", + how_to: "Intelligence > Memory Sources — toggle a source, open its gear for per-source \ + limits, or use \"All In\". Programmatic: openhuman.memory_sources_update and \ + openhuman.memory_sources_apply_all_in (RPC).", + status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, + }, Capability { id: "intelligence.embedding_provider_config", name: "Configure Embedding Provider", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 40e35b9bed5..d7c215d87b1 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -106,6 +106,7 @@ fn catalog_includes_additional_user_facing_surfaces() { "intelligence.embedding_provider_config", "intelligence.embedding_provider_test", "intelligence.github_repo_memory_source", + "intelligence.memory_source_sync_controls", "conversation.subagent_mascots", ] { assert!( From da321806687a5d8a1b47904c7a63484a78dd3a9a Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Wed, 3 Jun 2026 19:31:22 +0530 Subject: [PATCH 03/11] test(memory_sync): add ProviderContext caps fields to integration test sites cargo llvm-cov compiles tests/ integration crates (which plain cargo check skips), surfacing 8 ProviderContext literals missing the new max_items / sync_depth_days fields. Add None defaults so the coverage build compiles. --- tests/memory_sync_providers_raw_coverage_e2e.rs | 4 ++++ tests/memory_sync_round23_raw_coverage_e2e.rs | 2 ++ tests/memory_sync_slack_bus_raw_coverage_e2e.rs | 2 ++ tests/memory_sync_sources_raw_coverage_e2e.rs | 2 ++ tests/memory_sync_tree_round21_raw_coverage_e2e.rs | 2 ++ tests/memory_threads_raw_coverage_e2e.rs | 2 ++ tests/memory_tree_sync_raw_coverage_e2e.rs | 2 ++ 7 files changed, 16 insertions(+) diff --git a/tests/memory_sync_providers_raw_coverage_e2e.rs b/tests/memory_sync_providers_raw_coverage_e2e.rs index d0e9388fc03..088d5925f63 100644 --- a/tests/memory_sync_providers_raw_coverage_e2e.rs +++ b/tests/memory_sync_providers_raw_coverage_e2e.rs @@ -300,6 +300,8 @@ async fn configured_loopback_context( toolkit: toolkit.to_string(), connection_id: Some(connection_id.to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; (config, ctx, server) } @@ -552,6 +554,8 @@ async fn github_clickup_and_composio_bus_cover_provider_branches() { toolkit: "clickup".to_string(), connection_id: Some("conn-clickup-round17".to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let clickup = ClickUpProvider::new(); let click_profile = clickup diff --git a/tests/memory_sync_round23_raw_coverage_e2e.rs b/tests/memory_sync_round23_raw_coverage_e2e.rs index 9ebbf36ecac..956782c9ecb 100644 --- a/tests/memory_sync_round23_raw_coverage_e2e.rs +++ b/tests/memory_sync_round23_raw_coverage_e2e.rs @@ -165,6 +165,8 @@ async fn configured_context( toolkit: toolkit.to_string(), connection_id: Some(connection_id.to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; (config, ctx, server) } diff --git a/tests/memory_sync_slack_bus_raw_coverage_e2e.rs b/tests/memory_sync_slack_bus_raw_coverage_e2e.rs index e3726de349f..c153ac2cc9b 100644 --- a/tests/memory_sync_slack_bus_raw_coverage_e2e.rs +++ b/tests/memory_sync_slack_bus_raw_coverage_e2e.rs @@ -260,6 +260,8 @@ async fn configured_loopback_context( toolkit: "slack".to_string(), connection_id: Some("conn-slack-round19".to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; (config, ctx, server) } diff --git a/tests/memory_sync_sources_raw_coverage_e2e.rs b/tests/memory_sync_sources_raw_coverage_e2e.rs index 9c0702457af..65f8ed3bc89 100644 --- a/tests/memory_sync_sources_raw_coverage_e2e.rs +++ b/tests/memory_sync_sources_raw_coverage_e2e.rs @@ -508,6 +508,8 @@ async fn composio_providers_fetch_profiles_tasks_and_cover_error_branches() { toolkit: "github".to_string(), connection_id: Some("conn-github".to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let github = GitHubProvider::new(); let github_profile = github diff --git a/tests/memory_sync_tree_round21_raw_coverage_e2e.rs b/tests/memory_sync_tree_round21_raw_coverage_e2e.rs index 7343e86024b..f25cfe9c43e 100644 --- a/tests/memory_sync_tree_round21_raw_coverage_e2e.rs +++ b/tests/memory_sync_tree_round21_raw_coverage_e2e.rs @@ -239,6 +239,8 @@ async fn configured_loopback_context( toolkit: toolkit.to_string(), connection_id: Some(connection_id.to_string()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; (config, ctx, server) } diff --git a/tests/memory_threads_raw_coverage_e2e.rs b/tests/memory_threads_raw_coverage_e2e.rs index 406fad5c96f..d7081b92fc0 100644 --- a/tests/memory_threads_raw_coverage_e2e.rs +++ b/tests/memory_threads_raw_coverage_e2e.rs @@ -3047,6 +3047,8 @@ async fn memory_sync_provider_trait_defaults_and_connection_hook_are_determinist toolkit: "raw_coverage".into(), connection_id: Some("conn-1".into()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let provider = RawCoverageProvider { fail_profile: true }; assert_eq!(provider.sync_interval_secs(), Some(15 * 60)); diff --git a/tests/memory_tree_sync_raw_coverage_e2e.rs b/tests/memory_tree_sync_raw_coverage_e2e.rs index bd31d866696..821e75faeb6 100644 --- a/tests/memory_tree_sync_raw_coverage_e2e.rs +++ b/tests/memory_tree_sync_raw_coverage_e2e.rs @@ -599,6 +599,8 @@ async fn default_composio_provider_hooks_return_expected_noop_shapes() { toolkit: "round14".into(), connection_id: Some("conn-round14".into()), usage: Default::default(), + max_items: None, + sync_depth_days: None, }; let provider = MinimalProvider; assert_eq!(provider.sync_interval_secs(), None); From 43334c63ed393fe0e49601275b89f42d0f04e76d Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Wed, 3 Jun 2026 19:49:02 +0530 Subject: [PATCH 04/11] test(e2e): expose memory_sources_apply_all_in in controller schema catalog worker_a_controller_schemas_are_fully_exposed pins the exact memory_sources RPC method list; add the new openhuman.memory_sources_apply_all_in entry. --- tests/config_auth_app_state_connectivity_e2e.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 3bb783f56d2..5df2d421b66 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2855,6 +2855,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "memory_sources", vec![ "openhuman.memory_sources_add", + "openhuman.memory_sources_apply_all_in", "openhuman.memory_sources_estimate_sync_cost", "openhuman.memory_sources_get", "openhuman.memory_sources_list", From 6aab51382ae744f8ec4ff9ced1a4e2370ef02f7e Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Wed, 3 Jun 2026 20:06:03 +0530 Subject: [PATCH 05/11] fix(memory-sources): render per-source settings panel full-width below the row The SourceSettingsPanel was a third flex child of the row's sm:flex-row container, so on wider screens it docked to the right of the action buttons and crushed the source info/stats column into a one-word-per-line sliver. Wrap the info + actions in their own flex row and render the settings panel as a full-width block beneath it. --- .../intelligence/MemorySourcesRegistry.tsx | 230 +++++++++--------- 1 file changed, 115 insertions(+), 115 deletions(-) diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 755c173b720..8ab8210becd 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -387,137 +387,137 @@ function SourceRow({ const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null; return ( -
  • -
    -
    - {icon} - - {source.label} - - - {kindLabel} - - {status && status.chunks_synced > 0 && } -
    - {detail && ( -

    - {detail} -

    - )} - {progress && ( -
    -
    - {progress.stage} - {progress.percent !== null && ( - - {progress.percent}% +
  • +
    +
    +
    + {icon} + + {source.label} + + + {kindLabel} + + {status && status.chunks_synced > 0 && } +
    + {detail && ( +

    + {detail} +

    + )} + {progress && ( +
    +
    + {progress.stage} + {progress.percent !== null && ( + + {progress.percent}% + + )} + {progress.detail && ( + + {progress.detail} + + )} +
    +
    +
    +
    +
    + )} + {!progress && status && (status.chunks_synced > 0 || status.chunks_pending > 0) && ( +
    + + {status.chunks_synced.toLocaleString()} {t('sync.chunks')} + + {lastSync && ( + + {t('sync.lastChunk')} {lastSync} )} - {progress.detail && ( - - {progress.detail} + {status.chunks_pending > 0 && ( + + {status.chunks_pending.toLocaleString()} {t('sync.pending')} )}
    -
    -
    -
    -
    - )} - {!progress && status && (status.chunks_synced > 0 || status.chunks_pending > 0) && ( -
    - - {status.chunks_synced.toLocaleString()} {t('sync.chunks')} - - {lastSync && ( - - {t('sync.lastChunk')} {lastSync} - - )} - {status.chunks_pending > 0 && ( - - {status.chunks_pending.toLocaleString()} {t('sync.pending')} - - )} -
    - )} -
    -
    - -
    +
    + + - + - - + + + + +
    {settingsExpanded && ( From ec7df624a3718e1d8caa32d01f63fb41c0936e4b Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Thu, 4 Jun 2026 00:00:19 +0530 Subject: [PATCH 06/11] fix(memory-sources): backend-driven default caps + Unlimited/Maxed UI Make the conservative sync caps fully backend-owned and surface them honestly in the settings panel instead of hardcoding defaults in the frontend. Core: - Rename composio_defaults_for_toolkit -> memory_sync_defaults_for_toolkit (generic name; single source of truth for per-toolkit sync defaults). - Broaden the caps migration to apply defaults to ANY cap-less Composio source, enabled or disabled (an already-enabled cap-less source previously synced at the provider's internal ceiling instead of the cheap default). Extract a pure apply_caps_defaults_to_entries() shared by the migration and its tests. - Replace the composio_source_caps_migrated bool with a u32 composio_source_caps_migration_version gate so an improved migration re-runs once on installs that ran an earlier revision (Config has no deny_unknown_fields, so the retired bool is ignored). App: - Revert the frontend hardcoded defaults map; the panel now displays the entry's stored cap (fetched from the backend; the migration persists the conservative default onto the entry). - Empty field renders 'Unlimited' (was 'Leave blank for default'); show a 'Maxed' badge on count caps when the source's synced count >= the cap. - New i18n keys memorySources.settings.{unlimited,maxed} across all 13 locales. --- .../intelligence/MemorySourcesRegistry.tsx | 7 +- .../intelligence/SourceSettingsPanel.tsx | 88 +++++++++---- app/src/lib/i18n/ar.ts | 3 +- app/src/lib/i18n/bn.ts | 3 +- app/src/lib/i18n/de.ts | 3 +- app/src/lib/i18n/en.ts | 3 +- app/src/lib/i18n/es.ts | 3 +- app/src/lib/i18n/fr.ts | 3 +- app/src/lib/i18n/hi.ts | 3 +- app/src/lib/i18n/id.ts | 3 +- app/src/lib/i18n/it.ts | 3 +- app/src/lib/i18n/ko.ts | 3 +- app/src/lib/i18n/pl.ts | 3 +- app/src/lib/i18n/pt.ts | 3 +- app/src/lib/i18n/ru.ts | 3 +- app/src/lib/i18n/zh-CN.ts | 3 +- src/openhuman/config/schema/types.rs | 19 +-- src/openhuman/memory_sources/mod.rs | 6 +- src/openhuman/memory_sources/reconcile.rs | 118 ++++++++++-------- src/openhuman/memory_sources/registry.rs | 32 +++-- 20 files changed, 192 insertions(+), 120 deletions(-) diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 8ab8210becd..8b36eba8232 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -520,7 +520,12 @@ function SourceRow({
    {settingsExpanded && ( - + )}
  • ); diff --git a/app/src/components/intelligence/SourceSettingsPanel.tsx b/app/src/components/intelligence/SourceSettingsPanel.tsx index 74f28094884..e340ca936b4 100644 --- a/app/src/components/intelligence/SourceSettingsPanel.tsx +++ b/app/src/components/intelligence/SourceSettingsPanel.tsx @@ -59,22 +59,42 @@ type LimitFields = Pick< | 'max_cost_per_sync_usd' >; +// Item-count caps where a "Maxed" badge is meaningful (synced count vs cap). +// Time-window (sync_depth_days/since_days) and budget (tokens/cost) caps don't +// map to a chunk count, so they never show "Maxed". +const COUNT_FIELDS = new Set([ + 'max_items', + 'max_prs', + 'max_issues', + 'max_commits', +]); + interface SourceSettingsPanelProps { source: MemorySourceEntry; + /** Chunks already synced for this source — drives the "Maxed" badge. */ + syncedCount?: number; onSaved: (updated: MemorySourceEntry) => void; onToast?: (toast: { type: 'success' | 'error'; title: string; message?: string }) => void; } -export function SourceSettingsPanel({ source, onSaved, onToast }: SourceSettingsPanelProps) { +export function SourceSettingsPanel({ + source, + syncedCount, + onSaved, + onToast, +}: SourceSettingsPanelProps) { const { t } = useT(); const fields = KIND_FIELDS[source.kind] ?? []; - // Hold each field as a string so inputs can be freely edited + // Hold each field as a string so inputs can be freely edited. Seeded from the + // entry's stored cap (fetched from the backend — the single source of truth; + // the caps migration writes conservative defaults onto the entry). An empty + // field genuinely means "no cap / unlimited". const [values, setValues] = useState>(() => { const init: Record = {}; for (const f of fields) { - const v = source[f as keyof MemorySourceEntry]; - init[f] = v != null ? String(v) : ''; + const stored = source[f as keyof MemorySourceEntry]; + init[f] = stored != null ? String(stored) : ''; } return init; }); @@ -120,29 +140,43 @@ export function SourceSettingsPanel({ source, onSaved, onToast }: SourceSettings {t('memorySources.settings.title')}

    - {fields.map(field => ( -
    - - handleChange(field, e.target.value)} - placeholder={t('memorySources.settings.unlimitedHint')} - className="w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-xs font-mono - text-stone-800 placeholder:text-stone-400 - dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-200 - dark:placeholder:text-neutral-500 - focus:outline-none focus:ring-2 focus:ring-primary-200" - /> -
    - ))} + {fields.map(field => { + const cap = Number(values[field]); + const isMaxed = + COUNT_FIELDS.has(field) && + values[field] !== '' && + Number.isFinite(cap) && + typeof syncedCount === 'number' && + syncedCount >= cap; + return ( +
    + + handleChange(field, e.target.value)} + placeholder={t('memorySources.settings.unlimited')} + className="w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-xs font-mono + text-stone-800 placeholder:text-stone-400 + dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-200 + dark:placeholder:text-neutral-500 + focus:outline-none focus:ring-2 focus:ring-primary-200" + /> +
    + ); + })}