Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick — stale note. This bullet says new-connection caps are (None, None) / bounded only by MAX_PAGES, but bus.rs on_connection_created now .unwrap_or_else(|| memory_sync_defaults_for_toolkit(...)), so the first sync is capped by the per-toolkit defaults. The note contradicts the shipped code.

- **`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.
371 changes: 257 additions & 114 deletions app/src/components/intelligence/MemorySourcesRegistry.tsx

Large diffs are not rendered by default.

224 changes: 224 additions & 0 deletions app/src/components/intelligence/SourceSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* 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.
// Only caps that are actually enforced at sync time are surfaced — the
// per-sync token/cost budgets are not yet wired into the sync loop, so they
// are intentionally omitted here.
const KIND_FIELDS: Record<SourceKind, Array<keyof LimitFields>> = {
composio: ['sync_depth_days', 'max_items'],
github_repo: ['max_prs', 'max_issues', 'max_commits', 'sync_depth_days'],
rss_feed: ['max_items', 'sync_depth_days'],
twitter_query: ['since_days'],
web_page: ['sync_depth_days'],
folder: ['sync_depth_days'],
};

// i18n key for each field label
const FIELD_LABEL_KEYS: Record<keyof LimitFields, string> = {
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',
};

type LimitFields = Pick<
MemorySourceEntry,
'max_prs' | 'max_issues' | 'max_commits' | 'max_items' | 'since_days' | 'sync_depth_days'
>;

// 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<keyof LimitFields>([
'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,
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. 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<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const f of fields) {
const stored = source[f as keyof MemorySourceEntry];
init[f] = stored != null ? String(stored) : '';
}
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<LimitFields> = {};
for (const f of fields) {
const raw = values[f];
if (raw !== '' && raw !== undefined) {
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
onToast?.({ type: 'error', title: t('memorySources.settings.saveFailed') });
return;
}
(patch as Record<string, number>)[f] = parsed;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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;

// Display name for the tooltip — the toolkit slug (title-cased) for Composio
// sources, else the source label.
const toolkitName = source.toolkit
? source.toolkit.charAt(0).toUpperCase() + source.toolkit.slice(1)
: source.label;
const unlimitedTooltip = t('memorySources.settings.unlimitedTooltip').replace(
'{toolkit}',
toolkitName
);

return (
<div
className="mt-2 ml-7 rounded-lg border border-stone-200 bg-stone-50 p-3 dark:border-neutral-700 dark:bg-neutral-800/60"
data-testid={`source-settings-panel-${source.id}`}>
<p className="mb-2 text-xs font-semibold text-stone-600 dark:text-neutral-300">
{t('memorySources.settings.title')}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.map(field => {
const cap = Number(values[field]);
const isUnlimited = (values[field] ?? '') === '';
const isMaxed =
COUNT_FIELDS.has(field) &&
!isUnlimited &&
Number.isFinite(cap) &&
typeof syncedCount === 'number' &&
syncedCount >= cap;
return (
<div key={field}>
<label
htmlFor={`src-setting-${source.id}-${field}`}
className="mb-0.5 flex items-center gap-1.5 text-xs font-medium text-stone-600 dark:text-neutral-400">
{t(FIELD_LABEL_KEYS[field])}
{isMaxed && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
{t('memorySources.settings.maxed')}
</span>
)}
{isUnlimited && (
<span
className="inline-flex cursor-help text-stone-400 dark:text-neutral-500"
title={unlimitedTooltip}
aria-label={unlimitedTooltip}>
<InfoIcon />
</span>
)}
</label>
<input
id={`src-setting-${source.id}-${field}`}
type="number"
min={0}
step={1}
value={values[field] ?? ''}
onChange={e => 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"
/>
</div>
);
})}
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
{saving ? t('memorySources.settings.saving') : t('memorySources.settings.save')}
</button>
</div>
</div>
);
}

function InfoIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);
}
Loading
Loading