Add connector memory extraction flow (#2265)

This commit is contained in:
Eli 2026-05-19 21:27:41 +08:00 committed by GitHub
parent a3a238247e
commit e94663bfbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 7346 additions and 961 deletions

View file

@ -132,6 +132,12 @@ function connectorToolSafetyHaystack(input: ConnectorToolSafetyClassificationInp
.join(' ');
}
function connectorToolPrimarySafetyHaystack(input: ConnectorToolSafetyClassificationInput): string {
return [input.name, input.title, ...(input.requiredScopes ?? [])]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.join(' ');
}
export function classifyConnectorToolSafety(input: ConnectorToolSafetyClassificationInput): ConnectorToolSafety {
const haystack = connectorToolSafetyHaystack(input);
if (destructiveHintPattern.test(haystack)) {
@ -141,18 +147,33 @@ export function classifyConnectorToolSafety(input: ConnectorToolSafetyClassifica
reason: 'Tool name, scope, or description contains destructive hints; destructive tools are not refreshable.',
};
}
if (writeHintPattern.test(haystack)) {
const primaryHaystack = connectorToolPrimarySafetyHaystack(input);
if (writeHintPattern.test(primaryHaystack)) {
return {
sideEffect: 'write',
approval: 'confirm',
reason: 'Tool name or required scope indicates write-capable behavior; explicit confirmation is required.',
};
}
if (readOnlyHintPattern.test(haystack)) {
if (readOnlyHintPattern.test(primaryHaystack)) {
return {
sideEffect: 'read',
approval: 'auto',
reason: 'Tool name, scope, or description indicates explicit read-only behavior.',
reason: 'Tool name or scope indicates explicit read-only behavior.',
};
}
if (writeHintPattern.test(input.description ?? '')) {
return {
sideEffect: 'write',
approval: 'confirm',
reason: 'Tool description indicates write-capable behavior; explicit confirmation is required.',
};
}
if (readOnlyHintPattern.test(input.description ?? '')) {
return {
sideEffect: 'read',
approval: 'auto',
reason: 'Tool description indicates explicit read-only behavior.',
};
}
return {

View file

@ -48,6 +48,7 @@ export const COMPOSIO_CURATION_OVERLAY: Readonly<Record<string, Readonly<Record<
},
notion: {
notion_search: { ...DAILY_DIGEST_CURATION, reason: 'Searching Notion pages and databases is useful for a daily recap.' },
notion_search_notion_page: { ...DAILY_DIGEST_CURATION, reason: 'Searching Notion pages and databases is useful for memory and digest context.' },
notion_fetch_database: { ...DAILY_DIGEST_CURATION, reason: 'Database reads can summarize recent tasks and notes.' },
notion_query_database: { ...DAILY_DIGEST_CURATION, reason: 'Database queries support recent activity summaries.' },
},

View file

@ -17,6 +17,16 @@ const DISCOVERY_CACHE_TTL_MS = 60_000;
const CUSTOM_AUTH_REQUIRED_MESSAGE = 'Composio does not have managed credentials for this toolkit.';
const PERSISTED_CATALOG_REFRESH_MS = 24 * 60 * 60 * 1000;
const COMPOSIO_READ_ONLY_TOOL_SAFETY_OVERRIDES = new Set([
'notion:notion_search_notion_page',
]);
const COMPOSIO_READ_ONLY_TOOL_SAFETY = {
sideEffect: 'read',
approval: 'auto',
reason: 'Provider-specific override: this Composio tool is a read-only search/list operation.',
} as const;
interface ComposioToolkitCatalogEntry {
name: string;
slug: string;
@ -1421,7 +1431,19 @@ function applyComposioToolCuration(
const overlay = COMPOSIO_CURATION_OVERLAY[connectorKey];
const toolKey = providerToolId ? normalizeProviderToolId(providerToolId) : undefined;
const curation = toolKey ? overlay?.[toolKey] : undefined;
return curation === undefined ? tool : { ...tool, curation: { ...(tool.curation ?? {}), ...curation } };
const safetyOverride = toolKey
? COMPOSIO_READ_ONLY_TOOL_SAFETY_OVERRIDES.has(`${connectorKey}:${toolKey}`)
: false;
const curated = curation === undefined
? tool
: { ...tool, curation: { ...(tool.curation ?? {}), ...curation } };
return safetyOverride
? {
...curated,
safety: { ...COMPOSIO_READ_ONLY_TOOL_SAFETY },
refreshEligible: true,
}
: curated;
}
function titleFromSlug(value: string): string {

File diff suppressed because it is too large Load diff

View file

@ -25,13 +25,17 @@
// config OpenAI key for openai/azure overrides) so a "I want to
// switch to OpenAI but reuse my existing key" change costs zero
// typing.
// 1. ANTHROPIC_API_KEY env → Claude Haiku 4.5 (cheapest + fastest path)
// 2. OPENAI_API_KEY env → gpt-4o-mini
// 3. media-config OpenAI BYOK → gpt-4o-mini
// 1. current Local CLI, when the caller passed `chatAgentId` and the
// agent supports headless one-shot output (Claude Code today).
// 2. matching provider env var for the current chat protocol.
// 3. BYOK chat-config snapshot for API-mode chats.
// 4. ANTHROPIC_API_KEY env → Claude Haiku 4.5 (legacy fallback)
// 5. OPENAI_API_KEY env → gpt-4o-mini
// 6. media-config OpenAI BYOK → gpt-4o-mini
// (the key the user already typed into Settings → Media providers;
// reuses an existing credential so Local-CLI users don't have to
// paste it twice just to get LLM-side memory extraction)
// 4. nothing → record a 'skipped: no-provider' attempt
// 7. nothing → record a 'skipped: no-provider' attempt
// so the UI can surface "configure a key to enable LLM memory"
// instead of staying silent
//
@ -56,6 +60,19 @@ import {
markFailed,
} from './memory-extractions.js';
import { resolveProviderConfig } from './media-config.js';
import { spawn } from 'node:child_process';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCommandInvocation } from '@open-design/platform';
import {
applyAgentLaunchEnv,
getAgentDef,
resolveAgentLaunch,
spawnEnvForAgent,
} from './agents.js';
import { agentCliEnvForAgent, readAppConfig } from './app-config.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
const SYSTEM_PROMPT = `You are a memory extractor for a personal AI design assistant.
@ -193,9 +210,35 @@ function chatProtocolFromAgentId(agentId) {
return null;
}
function canUseLocalCliForMemory(agentId, provider) {
// Keep this allowlist explicit: each entry below has a headless one-shot
// mode that accepts stdin and a parser we can reduce back to assistant text.
if (agentId === 'claude' && provider === 'anthropic') return true;
if (agentId === 'codex' && provider === 'openai') return true;
if (agentId === 'opencode' && provider === 'openai') return true;
return false;
}
function localCliProviderFor(agentId, provider, model) {
if (!canUseLocalCliForMemory(agentId, provider)) return null;
return {
kind: provider,
model: (typeof model === 'string' && model.trim()) || 'default',
baseUrl: 'local-cli',
apiVersion: '',
credentialSource: 'chat-cli',
transport: 'chat-cli',
agentId,
};
}
// Pick a provider in this order:
// 0. Memory config override → user-set provider/model/baseUrl/apiKey
// 1. Chat-protocol-constrained env var → if the chat is on Claude
// 1. Current Local CLI → if the user is chatting through Claude Code,
// run the same CLI in one-shot mode for extraction. This keeps
// "Same as chat" literal: no extra OpenAI/Anthropic key required
// just because the extraction happens in the background.
// 2. Chat-protocol-constrained env var → if the chat is on Claude
// Code (anthropic), only ANTHROPIC_API_KEY counts; Codex/OpenAI-
// compatible CLIs only consult OPENAI_API_KEY (and the media-
// config OpenAI key as a secondary fallback). This stops the
@ -203,7 +246,7 @@ function chatProtocolFromAgentId(agentId) {
// background" surprise — if the matching key isn't configured,
// we'd rather skip with 'no-provider' and surface that in the
// history than quietly run on a different vendor's key.
// 2. BYOK chat-config snapshot → for API-mode chats (the picker is
// 3. BYOK chat-config snapshot → for API-mode chats (the picker is
// on "Same as chat"), `/api/memory/extract` forwards the live
// chat provider/key/baseUrl/apiVersion as `chatProvider`. We use
// it directly with the per-protocol fast-model default so the
@ -213,14 +256,14 @@ function chatProtocolFromAgentId(agentId) {
// user-supplied `chatProvider.model` only when none was given —
// memory should default to a cheaper/faster model than the chat
// model the user is paying for.
// 3. (legacy fallback, only when we can't tell which CLI is in use
// 4. (legacy fallback, only when we can't tell which CLI is in use
// AND the caller didn't pass `chatProvider`)
// ANTHROPIC_API_KEY env → Claude Haiku 4.5
// 4. (legacy fallback) OPENAI_API_KEY env → gpt-4o-mini
// 5. (legacy fallback) media-config OpenAI BYOK → gpt-4o-mini
// 5. (legacy fallback) OPENAI_API_KEY env → gpt-4o-mini
// 6. (legacy fallback) media-config OpenAI BYOK → gpt-4o-mini
//
// The `OD_MEMORY_MODEL` env continues to override the model name across
// (1)(5) so power users don't lose that lever. It does NOT override the
// (1)(6) so power users don't lose that lever. It does NOT override the
// memory-config provider since that one carries an explicit user choice.
// `projectRoot` is required for the media-config path; `chatAgentId` is
// optional but recommended — without it we fall through to the legacy
@ -230,7 +273,10 @@ function chatProtocolFromAgentId(agentId) {
// through from the web app on a per-call basis (the daemon never
// persists BYOK creds, so this is the only signal we have for that
// mode).
async function pickProvider(projectRoot, dataDir, chatAgentId, chatProvider) {
async function pickProvider(projectRoot, dataDir, chatAgentId, chatProvider, chatModel) {
const chatProtocol = chatProtocolFromAgentId(chatAgentId);
const normalizedChatAgentId =
typeof chatAgentId === 'string' ? chatAgentId.trim().toLowerCase() : '';
let override = null;
if (dataDir) {
try {
@ -273,7 +319,15 @@ async function pickProvider(projectRoot, dataDir, chatAgentId, chatProvider) {
// Ignore — we'll record a no-provider skip below.
}
}
if (!resolvedKey) return null;
if (!resolvedKey) {
const localCliProvider = localCliProviderFor(
normalizedChatAgentId,
override.provider,
override.model,
);
if (localCliProvider) return localCliProvider;
return null;
}
const baseUrl =
(typeof override.baseUrl === 'string' && override.baseUrl.trim())
|| defaults.baseUrl;
@ -306,8 +360,14 @@ async function pickProvider(projectRoot, dataDir, chatAgentId, chatProvider) {
// env var for a different provider is set, because doing so produces
// the "I'm using Claude but memory says openai gpt-4o-mini" surprise
// the user reported.
const chatProtocol = chatProtocolFromAgentId(chatAgentId);
if (chatProtocol) {
const localCliProvider = localCliProviderFor(
normalizedChatAgentId,
chatProtocol,
process.env.OD_MEMORY_MODEL || chatModel,
);
if (localCliProvider) return localCliProvider;
const envKey = envKeyFor(chatProtocol);
if (envKey) {
const defaults = PROVIDER_DEFAULTS[chatProtocol];
@ -689,6 +749,198 @@ async function callGoogle(provider, system, user) {
return '';
}
const LOCAL_CLI_TIMEOUT_MS = 60_000;
function extractJsonEventText(kind, raw, agentName) {
const events = [];
const handler = createJsonEventStreamHandler(kind, (event) => events.push(event));
handler.feed(raw);
handler.flush();
const errorEvent = events.find((event) => event?.type === 'error');
if (errorEvent) {
const message =
typeof errorEvent.message === 'string' && errorEvent.message.trim()
? errorEvent.message.trim()
: 'unknown error';
throw new Error(`${agentName} CLI error: ${message}`);
}
return events
.filter((event) => event?.type === 'text_delta' && typeof event.delta === 'string')
.map((event) => event.delta)
.join('')
.trim();
}
async function writeLocalCliPromptAttachment(agentId, prompt) {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), `od-memory-${agentId}-`));
const file = path.join(dir, 'prompt.md');
await fsp.writeFile(file, prompt, 'utf8');
return {
file,
cleanup: () => fsp.rm(dir, { recursive: true, force: true }).catch(() => {}),
};
}
async function callLocalCli(provider, system, user, options) {
if (typeof options?.localCliRunner === 'function') {
return options.localCliRunner({
agentId: provider.agentId,
model: provider.model,
system,
user,
projectRoot: options?.projectRoot ?? null,
dataDir: options?.dataDir ?? null,
});
}
const def = getAgentDef(provider.agentId);
if (!def) {
throw new Error(`Local CLI agent "${provider.agentId}" is not installed`);
}
let configuredAgentEnv = {};
try {
const appConfig = options?.dataDir ? await readAppConfig(options.dataDir) : {};
configuredAgentEnv = agentCliEnvForAgent(appConfig.agentCliEnv, def.id);
} catch {
configuredAgentEnv = {};
}
const launch = resolveAgentLaunch(def, configuredAgentEnv);
if (!launch?.launchPath) {
throw new Error(`${def.name} CLI is not installed or not on PATH`);
}
const cwd =
typeof options?.projectRoot === 'string' && options.projectRoot.trim()
? options.projectRoot
: process.cwd();
const prompt = [
system,
'',
'You are running as a background memory extractor. Do not use tools. Return strict JSON only.',
'',
user,
].join('\n');
let args;
let stdinText = prompt;
let cleanupPromptAttachment = () => Promise.resolve();
let parseStdout = (raw) => raw.trim();
if (provider.agentId === 'claude') {
args = ['-p', '--input-format', 'text', '--output-format', 'text'];
if (provider.model && provider.model !== 'default') {
args.push('--model', provider.model);
}
} else if (provider.agentId === 'codex') {
args = def.buildArgs(
'',
[],
[],
{ model: provider.model },
{ cwd },
);
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
} else if (provider.agentId === 'opencode') {
const attachment = await writeLocalCliPromptAttachment(provider.agentId, prompt);
cleanupPromptAttachment = attachment.cleanup;
args = def.buildArgs(
'',
[],
[],
{ model: provider.model },
{ cwd },
);
args.push(
'--file',
attachment.file,
'Read the attached OpenDesign memory extraction prompt and return strict JSON only.',
);
stdinText = '';
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
} else {
throw new Error(`Local CLI memory extraction is not supported for ${provider.agentId}`);
}
const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
launch,
);
const invocation = createCommandInvocation({
command: launch.launchPath,
args,
env,
});
return await new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let settled = false;
let closed = false;
const child = spawn(invocation.command, invocation.args, {
env,
stdio: ['pipe', 'pipe', 'pipe'],
cwd,
shell: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
const finish = (err, text) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
void cleanupPromptAttachment().finally(() => {
if (err) reject(err);
else resolve(text);
});
};
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => {
if (!closed) child.kill('SIGKILL');
}, 2_000).unref?.();
finish(new Error(`${def.name} CLI timed out after ${Math.round(LOCAL_CLI_TIMEOUT_MS / 1000)}s`));
}, LOCAL_CLI_TIMEOUT_MS);
timeout.unref?.();
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout = `${stdout}${chunk}`.slice(-64_000);
});
child.stderr.on('data', (chunk) => {
stderr = `${stderr}${chunk}`.slice(-8_000);
});
child.once('error', (err) => finish(err));
child.once('close', (code, signal) => {
closed = true;
if (code === 0) {
let text = '';
try {
text = parseStdout(stdout);
} catch (err) {
finish(err);
return;
}
if (text) {
finish(null, text);
return;
}
}
const detail = (stderr.trim() || stdout.trim() || 'no output').slice(0, 1000);
const status = signal ? `signal ${signal}` : `exit ${code}`;
finish(new Error(`${def.name} CLI ${status}: ${detail}`));
});
child.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') finish(err);
});
child.stdin.end(stdinText);
});
}
// Tolerant JSON parse — the model occasionally wraps output in ```json
// fences even when told not to. Strip those defensively.
function parseEntries(rawText) {
@ -734,9 +986,24 @@ function alreadyKnown(existing, candidate) {
return false;
}
export async function extractWithLLM(dataDir, input, options) {
function toMemoryDraft(candidate) {
return {
type: candidate.type,
name: String(candidate.name).trim().slice(0, 80),
description: String(candidate.description || '').trim().slice(0, 200),
body: String(candidate.body).trim(),
};
}
async function collectProposedEntries(dataDir, input, options) {
const projectRoot = options?.projectRoot ?? null;
const chatAgentId = options?.chatAgentId ?? null;
const chatModel = options?.chatModel ?? null;
const extractionKind = options?.kind ?? 'llm';
const systemPrompt =
typeof options?.systemPrompt === 'string' && options.systemPrompt.trim()
? options.systemPrompt.trim()
: SYSTEM_PROMPT;
// BYOK chat-config snapshot — only present for API-mode calls
// forwarded through `/api/memory/extract`. The daemon doesn't
// persist BYOK creds, so this per-call signal is the *only* way
@ -747,12 +1014,15 @@ export async function extractWithLLM(dataDir, input, options) {
const cfg = await readMemoryConfig(dataDir);
if (!cfg.enabled) {
recordSkip({ userMessage, reason: 'memory-disabled' });
return [];
recordSkip({ userMessage, reason: 'memory-disabled', kind: extractionKind });
return { status: 'skipped', attemptId: null, proposed: [], existingEntries: [] };
}
if (extractionKind !== 'connector' && !cfg.chatExtractionEnabled) {
return { status: 'skipped', attemptId: null, proposed: [], existingEntries: [] };
}
if (userMessage.length === 0) {
recordSkip({ userMessage, reason: 'empty-message' });
return [];
recordSkip({ userMessage, reason: 'empty-message', kind: extractionKind });
return { status: 'skipped', attemptId: null, proposed: [], existingEntries: [] };
}
const provider = await pickProvider(
@ -760,16 +1030,17 @@ export async function extractWithLLM(dataDir, input, options) {
dataDir,
chatAgentId,
chatProvider,
chatModel,
);
if (!provider) {
recordSkip({ userMessage, reason: 'no-provider' });
return [];
recordSkip({ userMessage, reason: 'no-provider', kind: extractionKind });
return { status: 'skipped', attemptId: null, proposed: [], existingEntries: [] };
}
// Past this point we have a provider committed and an actual model
// call about to happen — switch from one-shot skip records to a
// running record we can update through phase transitions.
const attemptId = startExtraction({ userMessage });
const attemptId = startExtraction({ userMessage, kind: extractionKind });
markProvider(attemptId, {
kind: provider.kind,
model: provider.model,
@ -795,17 +1066,23 @@ export async function extractWithLLM(dataDir, input, options) {
let raw = '';
try {
if (provider.kind === 'anthropic') {
raw = await callAnthropic(provider, SYSTEM_PROMPT, userPayload);
if (provider.transport === 'chat-cli') {
raw = await callLocalCli(provider, systemPrompt, userPayload, {
dataDir,
projectRoot,
localCliRunner: options?.localCliRunner,
});
} else if (provider.kind === 'anthropic') {
raw = await callAnthropic(provider, systemPrompt, userPayload);
} else if (provider.kind === 'azure') {
raw = await callAzure(provider, SYSTEM_PROMPT, userPayload);
raw = await callAzure(provider, systemPrompt, userPayload);
} else if (provider.kind === 'google') {
raw = await callGoogle(provider, SYSTEM_PROMPT, userPayload);
raw = await callGoogle(provider, systemPrompt, userPayload);
} else {
// openai or ollama — both speak the OpenAI chat-completions
// wire shape, so callOpenAI handles them with just a different
// base URL.
raw = await callOpenAI(provider, SYSTEM_PROMPT, userPayload);
raw = await callOpenAI(provider, systemPrompt, userPayload);
}
} catch (err) {
// err.message is already pre-formatted by describeFetchError() when
@ -813,17 +1090,51 @@ export async function extractWithLLM(dataDir, input, options) {
// (`anthropic 401: …`) the message is already user-facing too.
console.warn(`[memory-llm] ${provider.kind} call failed`, err?.message ?? err);
markFailed(attemptId, err);
return [];
return { status: 'failed', attemptId, proposed: [], existingEntries };
}
let proposed;
try {
proposed = parseEntries(raw);
if (typeof options?.candidateFilter === 'function') {
proposed = proposed.filter((candidate) => {
try {
return options.candidateFilter(candidate);
} catch {
return false;
}
});
}
} catch (err) {
markFailed(attemptId, err);
return [];
return { status: 'failed', attemptId, proposed: [], existingEntries };
}
markProposed(attemptId, proposed.length);
return { status: 'ok', attemptId, proposed, existingEntries };
}
export async function suggestWithLLM(dataDir, input, options) {
const result = await collectProposedEntries(dataDir, input, options);
if (result.status !== 'ok') return [];
const suggestions = result.proposed
.filter((cand) => !alreadyKnown(result.existingEntries, cand))
.map(toMemoryDraft);
markSuccess(result.attemptId, {
writtenCount: 0,
writtenIds: [],
});
return suggestions;
}
export async function extractWithLLM(dataDir, input, options) {
const changeSource = options?.source ?? 'llm';
const result = await collectProposedEntries(dataDir, input, options);
if (result.status !== 'ok') return [];
const { attemptId, proposed, existingEntries } = result;
if (proposed.length === 0) {
markSuccess(attemptId, { writtenCount: 0, writtenIds: [] });
return [];
@ -835,15 +1146,10 @@ export async function extractWithLLM(dataDir, input, options) {
try {
const entry = await upsertMemoryEntry(
dataDir,
{
type: cand.type,
name: String(cand.name).trim().slice(0, 80),
description: String(cand.description || '').trim().slice(0, 200),
body: String(cand.body).trim(),
},
toMemoryDraft(cand),
// Suppress per-entry events; we batch a single 'extract' below
// so the toast says "Memory updated (3 · LLM)" once.
{ silent: true, source: 'llm' },
{ silent: true, source: changeSource },
);
written.push({
id: entry.id,
@ -861,7 +1167,7 @@ export async function extractWithLLM(dataDir, input, options) {
memoryEvents.emit('change', {
kind: 'extract',
count: written.length,
source: 'llm',
source: changeSource,
at: Date.now(),
});
}

View file

@ -4,7 +4,7 @@
// Layout (under <dataDir>/memory/):
// MEMORY.md ← short index; one bullet per fact file
// <type>_<slug>.md ← per-fact body + frontmatter
// .config.json ← single boolean: { "enabled": true }
// .config.json ← switches: { "enabled": true, "chatExtractionEnabled": true }
//
// Frontmatter format (matches Claude Code's auto-memory pattern):
// ---
@ -175,18 +175,19 @@ export async function readMemoryConfig(dataDir) {
const parsed = JSON.parse(raw);
return {
enabled: parsed?.enabled !== false,
chatExtractionEnabled: parsed?.chatExtractionEnabled !== false,
extraction: normalizeExtractionPatch(parsed?.extraction),
};
} catch {
// Default-on. The whole point of the feature is to surface user
// context across runs; making it opt-in would mean the first 3
// chats happen with no memory and no warning.
return { enabled: true, extraction: null };
return { enabled: true, chatExtractionEnabled: true, extraction: null };
}
}
// Patch shape:
// { enabled?: boolean, extraction?: object | null }
// { enabled?: boolean, chatExtractionEnabled?: boolean, extraction?: object | null }
// `extraction: null` clears the override (reverting to auto-pick); an
// object replaces it whole; an absent key leaves the existing override
// untouched.
@ -195,6 +196,10 @@ export async function writeMemoryConfig(dataDir, patch) {
const next = {
enabled:
typeof patch?.enabled === 'boolean' ? patch.enabled : current.enabled,
chatExtractionEnabled:
typeof patch?.chatExtractionEnabled === 'boolean'
? patch.chatExtractionEnabled
: current.chatExtractionEnabled,
extraction: current.extraction,
};
if (Object.prototype.hasOwnProperty.call(patch || {}, 'extraction')) {
@ -203,9 +208,15 @@ export async function writeMemoryConfig(dataDir, patch) {
: normalizeExtractionPatch(patch.extraction);
}
if (typeof next.enabled !== 'boolean') next.enabled = true;
if (typeof next.chatExtractionEnabled !== 'boolean') {
next.chatExtractionEnabled = true;
}
await ensureDir(memoryDir(dataDir));
await fsp.writeFile(configPath(dataDir), JSON.stringify(next, null, 2));
if (current.enabled !== next.enabled) {
if (
current.enabled !== next.enabled
|| current.chatExtractionEnabled !== next.chatExtractionEnabled
) {
emitChange({ kind: 'config', enabled: next.enabled });
}
// We don't emit a separate change event for extraction overrides — the
@ -765,6 +776,9 @@ export async function extractFromMessage(dataDir, userMessage) {
recordSkip({ userMessage, reason: 'memory-disabled', kind: 'heuristic' });
return [];
}
if (!cfg.chatExtractionEnabled) {
return [];
}
const seen = new Set();
const changed = [];
for (const pattern of REMEMBER_PATTERNS) {

View file

@ -138,6 +138,10 @@ import {
listExtractions as listMemoryExtractions,
removeExtraction as removeMemoryExtraction,
} from './memory-extractions.js';
import {
extractMemoryFromConnectors,
suggestMemoryFromConnectors,
} from './memory-connectors.js';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import {
@ -211,6 +215,7 @@ import { generateMedia } from './media.js';
import { listElevenLabsVoiceOptions } from './elevenlabs-voices.js';
import { searchResearch, ResearchError } from './research/index.js';
import { renderResearchCommandContract } from './prompts/research-contract.js';
import { openBrowser } from './browser-open.js';
import {
AUDIO_DURATIONS_SEC,
AUDIO_MODELS_BY_KIND,
@ -3470,6 +3475,7 @@ export async function startServer({
]);
res.json({
enabled: config.enabled,
chatExtractionEnabled: config.chatExtractionEnabled,
rootDir: memoryDir(RUNTIME_DATA_DIR),
index,
entries,
@ -3531,6 +3537,9 @@ export async function startServer({
const body = req.body && typeof req.body === 'object' ? req.body : {};
const patch = {};
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled;
if (typeof body.chatExtractionEnabled === 'boolean') {
patch.chatExtractionEnabled = body.chatExtractionEnabled;
}
// Three-state extraction handling so the UI can: (a) leave the
// override alone (omit `extraction`), (b) clear it back to
// auto-pick (`extraction: null`), or (c) commit a custom override
@ -3593,6 +3602,7 @@ export async function startServer({
const next = await writeMemoryConfig(RUNTIME_DATA_DIR, patch);
res.json({
enabled: next.enabled,
chatExtractionEnabled: next.chatExtractionEnabled,
extraction: maskMemoryExtractionConfig(next.extraction),
});
} catch (err) {
@ -3650,10 +3660,104 @@ export async function startServer({
}
});
app.delete('/api/memory/extractions/:id', async (req, res) => {
try {
const removed = removeMemoryExtraction(req.params.id);
res.json({ removed });
app.delete('/api/memory/extractions/:id', async (req, res) => {
try {
const removed = removeMemoryExtraction(req.params.id);
res.json({ removed });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.post('/api/memory/connectors/suggest', requireLocalDaemonRequest, async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const connectorIds = Array.isArray(body.connectorIds)
? body.connectorIds
.filter((id) => typeof id === 'string')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, 12)
: undefined;
const query =
typeof body.query === 'string' ? body.query.trim().slice(0, 240) : '';
const projectId =
typeof body.projectId === 'string' && body.projectId.trim()
? body.projectId.trim()
: null;
const appConfig = await readAppConfig(RUNTIME_DATA_DIR).catch(() => ({}));
const chatAgentId =
typeof body.chatAgentId === 'string' && body.chatAgentId.trim()
? body.chatAgentId.trim()
: typeof appConfig.agentId === 'string' && appConfig.agentId.trim()
? appConfig.agentId.trim()
: null;
const requestChatModel =
typeof body.chatModel === 'string' && body.chatModel.trim()
? body.chatModel.trim()
: null;
const chatModel =
requestChatModel
|| (chatAgentId && appConfig.agentModels?.[chatAgentId]?.model
? appConfig.agentModels[chatAgentId].model
: null);
const result = await suggestMemoryFromConnectors(RUNTIME_DATA_DIR, {
projectsRoot: PROJECTS_DIR,
projectRoot: PROJECT_ROOT,
projectId,
connectorIds,
query,
chatAgentId,
chatModel,
});
res.json(result);
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.post('/api/memory/connectors/extract', requireLocalDaemonRequest, async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const connectorIds = Array.isArray(body.connectorIds)
? body.connectorIds
.filter((id) => typeof id === 'string')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, 12)
: undefined;
const query =
typeof body.query === 'string' ? body.query.trim().slice(0, 240) : '';
const projectId =
typeof body.projectId === 'string' && body.projectId.trim()
? body.projectId.trim()
: null;
const appConfig = await readAppConfig(RUNTIME_DATA_DIR).catch(() => ({}));
const chatAgentId =
typeof body.chatAgentId === 'string' && body.chatAgentId.trim()
? body.chatAgentId.trim()
: typeof appConfig.agentId === 'string' && appConfig.agentId.trim()
? appConfig.agentId.trim()
: null;
const requestChatModel =
typeof body.chatModel === 'string' && body.chatModel.trim()
? body.chatModel.trim()
: null;
const chatModel =
requestChatModel
|| (chatAgentId && appConfig.agentModels?.[chatAgentId]?.model
? appConfig.agentModels[chatAgentId].model
: null);
const result = await extractMemoryFromConnectors(RUNTIME_DATA_DIR, {
projectsRoot: PROJECTS_DIR,
projectRoot: PROJECT_ROOT,
projectId,
connectorIds,
query,
chatAgentId,
chatModel,
});
res.json(result);
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
@ -3687,6 +3791,10 @@ export async function startServer({
const assistantMessage =
typeof body.assistantMessage === 'string' ? body.assistantMessage : '';
const hasAssistant = assistantMessage.trim().length > 0;
const memoryConfig = await readMemoryConfig(RUNTIME_DATA_DIR);
if (memoryConfig.chatExtractionEnabled === false) {
return res.json({ changed: [], attemptedLLM: false });
}
const changed = hasAssistant
? []
: await extractFromMessage(RUNTIME_DATA_DIR, userMessage);
@ -8030,11 +8138,35 @@ export async function startServer({
}
});
// Native OS folder picker dialog. Returns { path: string | null }.
app.post('/api/dialog/open-folder', async (req, res) => {
app.post('/api/system/open-external', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
let parsed;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ ok: false, error: 'url must be a valid URL' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return res.status(400).json({ ok: false, error: 'url must be http or https' });
}
const child = openBrowser(parsed.toString());
res.json({ ok: Boolean(child) });
} catch (err) {
res
.status(500)
.json({ ok: false, error: String(err && err.message ? err.message : err) });
}
});
// Native OS folder picker dialog. Returns { path: string | null }.
app.post('/api/dialog/open-folder', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const selected = await openNativeFolderDialog();
res.json({ path: selected });
@ -9512,11 +9644,12 @@ export async function startServer({
userMessage: userMsg,
assistantMessage: captured,
},
{
projectRoot: PROJECT_ROOT,
chatAgentId: typeof agentId === 'string' ? agentId : null,
},
),
{
projectRoot: PROJECT_ROOT,
chatAgentId: typeof agentId === 'string' ? agentId : null,
chatModel: typeof safeModel === 'string' ? safeModel : null,
},
),
)
.catch((err) => console.warn('[memory-llm] background failed', err));
});

View file

@ -21,6 +21,13 @@ async function useTempComposioStore(): Promise<string> {
return dir;
}
function composioJson(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function composioDefinition(id = 'github'): ConnectorCatalogDefinition {
return {
id,
@ -207,4 +214,72 @@ describe('composio config', () => {
await rm(defaultCachePath, { force: true });
}
});
it('treats the current Notion search action as read-only despite broad response-size wording', async () => {
await useTempComposioStore();
writeComposioConfig({ apiKey: 'cmp_test' });
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const parsed = new URL(input.toString(), 'https://backend.composio.dev');
if (parsed.pathname === '/api/v3/auth_configs') {
return composioJson({
items: [
{ id: 'ac_notion', status: 'ENABLED', toolkit: { slug: 'notion' } },
],
});
}
if (parsed.pathname === '/api/v3.1/toolkits') {
return composioJson({
items: [
{ slug: 'notion', name: 'Notion', categories: [{ name: 'Productivity' }] },
],
});
}
if (
parsed.pathname === '/api/v3.1/tools'
&& parsed.searchParams.get('toolkit_slug') === 'notion'
) {
return composioJson({
items: [
{
slug: 'NOTION_SEARCH_NOTION_PAGE',
name: 'Search Notion pages and databases',
description:
'Searches Notion pages and databases by title. Database pages can create large responses for databases with many properties.',
toolkit: { slug: 'notion' },
input_parameters: {
type: 'object',
properties: {
query: { type: 'string' },
page_size: { type: 'integer', minimum: 1, maximum: 100 },
},
additionalProperties: false,
},
tags: [],
},
],
total_items: 1,
});
}
return composioJson({ items: [] });
}) as typeof fetch;
try {
const definition = await composioConnectorProvider.getPreviewDefinition('notion', {
toolsLimit: 1000,
});
const tool = definition?.tools.find(
(candidate) => candidate.name === 'notion.notion_search_notion_page',
);
expect(definition?.allowedToolNames).toContain('notion.notion_search_notion_page');
expect(tool).toMatchObject({
refreshEligible: true,
safety: { sideEffect: 'read', approval: 'auto' },
curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }),
});
} finally {
globalThis.fetch = originalFetch;
}
});
});

View file

@ -135,6 +135,7 @@ function mockComposioFetch(options: MockComposioFetchOptions = {}): void {
return composioJson({
items: [
{ slug: 'NOTION_SEARCH', name: 'Search Notion', description: 'Search Notion pages and databases.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false }, tags: ['read'] },
{ slug: 'NOTION_SEARCH_NOTION_PAGE', name: 'Search Notion pages and databases', description: 'Searches Notion pages and databases by title. Database pages can create large responses for databases with many properties.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { query: { type: 'string' }, page_size: { type: 'integer', minimum: 1, maximum: 100 } }, additionalProperties: false }, tags: [] },
{ slug: 'NOTION_FETCH_DATABASE', name: 'Fetch database', description: 'Read a Notion database.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { database_id: { type: 'string' } }, required: ['database_id'], additionalProperties: false }, tags: ['read'] },
{ slug: 'NOTION_GET_PAGE', name: 'Get page', description: 'Read a Notion page.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { page_id: { type: 'string' } }, required: ['page_id'], additionalProperties: false }, tags: ['read'] },
],
@ -1137,6 +1138,11 @@ describe('connector routes', () => {
expect(response.body.connectors.map((connector: ConnectorDetail) => connector.id)).toEqual(['notion']);
expect(response.body.connectors[0].tools).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'notion.notion_search' }),
expect.objectContaining({
name: 'notion.notion_search_notion_page',
safety: expect.objectContaining({ sideEffect: 'read', approval: 'auto' }),
curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }),
}),
expect.objectContaining({ name: 'notion.notion_fetch_database' }),
expect.objectContaining({ name: 'notion.notion_get_page' }),
]));

View file

@ -211,6 +211,18 @@ describe('connector read-only safety classification', () => {
expect(isRefreshEligibleConnectorToolSafety(safety)).toBe(true);
});
it('does not let response-size wording override a read-only search name', () => {
const safety = classifyConnectorToolSafety({
name: 'notion.notion_search_notion_page',
title: 'Search Notion pages and databases',
description:
'Searches Notion pages and databases by title. Database pages can create large responses for databases with many properties.',
});
expect(safety).toMatchObject({ sideEffect: 'read', approval: 'auto' });
expect(isRefreshEligibleConnectorToolSafety(safety)).toBe(true);
});
it('fails closed for unknown tools', () => {
const safety = classifyConnectorToolSafety({ name: 'provider.sync' });

File diff suppressed because it is too large Load diff

View file

@ -124,12 +124,14 @@ describe('memory routes', () => {
const json = await res.json() as {
enabled: boolean;
chatExtractionEnabled: boolean;
rootDir: string;
index: string;
entries: unknown[];
extraction: unknown;
};
expect(json.enabled).toBe(true);
expect(json.chatExtractionEnabled).toBe(true);
expect(json.rootDir).toBe(memoryDir(dataDir));
expect(json.index).toContain('# Memory');
expect(json.entries).toEqual([]);
@ -316,6 +318,37 @@ describe('memory routes', () => {
]);
});
it('does not extract chat memories when chat learning is disabled', async () => {
const configRes = await fetch(`${baseUrl}/api/memory/config`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chatExtractionEnabled: false }),
});
expect(configRes.status).toBe(200);
const res = await fetch(`${baseUrl}/api/memory/extract`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userMessage: 'Remember: prefer dark mode for UI examples.',
}),
});
expect(res.status).toBe(200);
const json = await res.json() as {
changed: Array<unknown>;
attemptedLLM: boolean;
};
expect(json.changed).toEqual([]);
expect(json.attemptedLLM).toBe(false);
const listRes = await fetch(`${baseUrl}/api/memory`);
const listJson = await listRes.json() as {
entries: Array<unknown>;
};
expect(listJson.entries).toEqual([]);
});
it('reports attemptedLLM for post-turn extraction requests without triggering a real provider call', async () => {
const res = await fetch(`${baseUrl}/api/memory/extract`, {
method: 'POST',

View file

@ -0,0 +1,158 @@
import {
useEffect,
useRef,
useState,
} from 'react';
import type { ConnectorDetail } from '@open-design/contracts';
const COMPOSIO_LOGO_SLUG_OVERRIDES: Record<string, string> = {
google_drive: 'googledrive',
};
/**
* Composio publishes per-toolkit logos at `logos.composio.dev`, keyed by the
* lowercased toolkit slug (`AIRTABLE` -> `airtable`, `ZOHO_BOOKS` ->
* `zoho_books`). Our connector ids are mostly already that shape. A small
* override map handles CDN exceptions such as Google Drive, whose logo slug
* is `googledrive` even though the toolkit id remains `google_drive`.
*/
function composioLogoSlug(connector: ConnectorDetail): string {
const normalized = connector.id.toLowerCase().replace(/[^a-z0-9_]/g, '');
return COMPOSIO_LOGO_SLUG_OVERRIDES[normalized] ?? normalized;
}
/**
* Build the Composio logo URL for a given connector + theme. Returns `null`
* when the slug normalizes to empty so the fallback tile renders without a
* pointless 404 round trip.
*/
function composioLogoUrl(
connector: ConnectorDetail,
theme: 'light' | 'dark',
): string | null {
const slug = composioLogoSlug(connector);
if (!slug) return null;
return `/api/connectors/logos/${encodeURIComponent(slug)}?theme=${theme}`;
}
/**
* Resolve the live theme from `<html data-theme>`, falling back to the OS
* preference when the user is on the implicit "system" mode (no attribute
* set). Lightweight on purpose the color of an icon doesn't deserve a
* full theme provider/context here. The hook listens for both the data
* attribute changing and the OS-level `prefers-color-scheme` toggling so
* the logo stays in lockstep with the rest of the chrome.
*/
export function useResolvedTheme(): 'light' | 'dark' {
const read = (): 'light' | 'dark' => {
if (typeof document === 'undefined') return 'dark';
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr;
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
};
const [theme, setTheme] = useState<'light' | 'dark'>(read);
useEffect(() => {
const update = () => setTheme(read());
update();
const observer = new MutationObserver(update);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
media?.addEventListener?.('change', update);
return () => {
observer.disconnect();
media?.removeEventListener?.('change', update);
};
}, []);
return theme;
}
/**
* Tiny hash -> palette index. Stable across reloads so a connector's
* fallback tile keeps the same hue, which makes the catalog feel coherent
* even when many logos are missing (e.g. dev fixtures, network blocked).
*/
function fallbackPaletteIndex(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 6;
}
function fallbackInitials(name: string): string {
const cleaned = name.trim();
if (!cleaned) return '?';
const parts = cleaned.split(/\s+/u);
if (parts.length === 1) {
const single = parts[0]!;
return (single[0] ?? '').toUpperCase() + (single[1] ?? '').toLowerCase();
}
const first = parts[0]?.[0] ?? '';
const second = parts[1]?.[0] ?? '';
return (first + second).toUpperCase();
}
/**
* Connector brand mark. Tries the Composio logo CDN first (theme-aware) and
* gracefully degrades to a colored initials tile if the request fails or no
* slug is derivable. Decorative by default the surrounding caption (card
* title / drawer heading) is the accessible label, so the image carries an
* empty alt and `aria-hidden="true"`.
*/
export function ConnectorLogo({
connector,
theme,
size = 'sm',
}: {
connector: ConnectorDetail;
theme: 'light' | 'dark';
/** `sm` for compact rows/cards, `lg` for the detail drawer mark. */
size?: 'sm' | 'lg';
}) {
const url = composioLogoUrl(connector, theme);
const imageRef = useRef<HTMLImageElement | null>(null);
const [state, setState] = useState<'pending' | 'loaded' | 'error'>(
url ? 'pending' : 'error',
);
useEffect(() => {
if (!url) {
setState('error');
return;
}
setState('pending');
const image = imageRef.current;
if (image?.complete) {
setState(image.naturalWidth > 0 ? 'loaded' : 'error');
}
}, [url]);
const initials = fallbackInitials(connector.name);
const palette = fallbackPaletteIndex(connector.id || connector.name);
const showImage = url !== null && state !== 'error';
return (
<span
className={`connector-logo size-${size} state-${state}${showImage ? '' : ' is-fallback'}`}
data-palette={palette}
aria-hidden="true"
>
{showImage ? (
<img
key={url}
ref={imageRef}
className="connector-logo-img"
src={url}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
draggable={false}
onLoad={() => setState('loaded')}
onError={() => setState('error')}
/>
) : null}
<span className="connector-logo-fallback">{initials}</span>
</span>
);
}

View file

@ -18,11 +18,13 @@ import {
fetchConnectorDiscovery,
fetchConnectors,
fetchConnectorStatuses,
openExternalUrl,
} from '../providers/registry';
import {
isTrustedConnectorCallbackOrigin,
sortConnectorsForSearch,
} from './EntryView';
import { ConnectorLogo, useResolvedTheme } from './ConnectorLogo';
import { Icon } from './Icon';
import { CenteredLoader } from './Loading';
@ -31,178 +33,15 @@ const CONNECTOR_AUTH_PENDING_STORAGE_KEY = 'od-connectors-authorization-pending'
const CONNECTOR_AUTH_PENDING_POLL_MS = 2_000;
const CONNECTOR_TOOL_PREVIEW_LIMIT = 50;
const AUTHORIZATION_CANCEL_FAILED_MESSAGE = "Couldn't cancel authorization. Try again.";
const CONNECTOR_AUTH_CONTINUE_LABEL = 'Continue in browser';
interface ConnectorAuthorizationPending {
expiresAt?: string;
redirectUrl?: string;
}
type ConnectorAuthorizationPendingState = Record<string, ConnectorAuthorizationPending>;
const COMPOSIO_LOGO_SLUG_OVERRIDES: Record<string, string> = {
google_drive: 'googledrive',
};
/**
* Composio publishes per-toolkit logos at `logos.composio.dev`, keyed by the
* lowercased toolkit slug (`AIRTABLE` `airtable`, `ZOHO_BOOKS`
* `zoho_books`). Our connector ids are mostly already that shape. A small
* override map handles CDN exceptions such as Google Drive, whose logo slug
* is `googledrive` even though the toolkit id remains `google_drive`.
*/
function composioLogoSlug(connector: ConnectorDetail): string {
const normalized = connector.id.toLowerCase().replace(/[^a-z0-9_]/g, '');
return COMPOSIO_LOGO_SLUG_OVERRIDES[normalized] ?? normalized;
}
/**
* Build the Composio logo URL for a given connector + theme. Returns `null`
* when the slug normalizes to empty so the fallback tile renders without a
* pointless 404 round trip.
*/
function composioLogoUrl(
connector: ConnectorDetail,
theme: 'light' | 'dark',
): string | null {
const slug = composioLogoSlug(connector);
if (!slug) return null;
return `/api/connectors/logos/${encodeURIComponent(slug)}?theme=${theme}`;
}
/**
* Resolve the live theme from `<html data-theme>`, falling back to the OS
* preference when the user is on the implicit "system" mode (no attribute
* set). Lightweight on purpose the color of an icon doesn't deserve a
* full theme provider/context here. The hook listens for both the data
* attribute changing and the OS-level `prefers-color-scheme` toggling so
* the logo stays in lockstep with the rest of the chrome.
*/
function useResolvedTheme(): 'light' | 'dark' {
const read = (): 'light' | 'dark' => {
if (typeof document === 'undefined') return 'dark';
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr;
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
};
const [theme, setTheme] = useState<'light' | 'dark'>(read);
useEffect(() => {
const update = () => setTheme(read());
update();
const observer = new MutationObserver(update);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
media?.addEventListener?.('change', update);
return () => {
observer.disconnect();
media?.removeEventListener?.('change', update);
};
}, []);
return theme;
}
/**
* Tiny hash palette index. Stable across reloads so a connector's
* fallback tile keeps the same hue, which makes the catalog feel coherent
* even when many logos are missing (e.g. dev fixtures, network blocked).
*/
function fallbackPaletteIndex(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 6;
}
function fallbackInitials(name: string): string {
const cleaned = name.trim();
if (!cleaned) return '?';
const parts = cleaned.split(/\s+/u);
if (parts.length === 1) {
const single = parts[0]!;
return (single[0] ?? '').toUpperCase() + (single[1] ?? '').toLowerCase();
}
const first = parts[0]?.[0] ?? '';
const second = parts[1]?.[0] ?? '';
return (first + second).toUpperCase();
}
/**
* Connector brand mark. Tries the Composio logo CDN first (theme-aware) and
* gracefully degrades to a colored initials tile if the request fails or no
* slug is derivable. Decorative by default the surrounding caption (card
* title / drawer heading) is the accessible label, so the image carries an
* empty alt and `aria-hidden="true"`.
*/
function ConnectorLogo({
connector,
theme,
size = 'sm',
}: {
connector: ConnectorDetail;
theme: 'light' | 'dark';
/** `sm` for catalog cards (compact 28px), `lg` for the detail drawer mark (44px). */
size?: 'sm' | 'lg';
}) {
const url = composioLogoUrl(connector, theme);
const imageRef = useRef<HTMLImageElement | null>(null);
// Track load state per (connector, theme, size) instance. Resetting on
// url change means switching themes mid-session retries the new URL
// instead of being stuck on a previously-failed request.
const [state, setState] = useState<'pending' | 'loaded' | 'error'>(
url ? 'pending' : 'error',
);
useEffect(() => {
if (!url) {
setState('error');
return;
}
setState('pending');
const image = imageRef.current;
// Some browsers can complete tiny cached SVGs before React's onLoad
// listener observes the event. The image is visually available, but the
// wrapper stays in `state-pending`, leaving the neutral fallback over it.
// Reconcile against the DOM image state after mount/theme changes so
// cached logos still promote to the visible loaded state.
if (image?.complete) {
setState(image.naturalWidth > 0 ? 'loaded' : 'error');
}
}, [url]);
const initials = fallbackInitials(connector.name);
const palette = fallbackPaletteIndex(connector.id || connector.name);
const showImage = url !== null && state !== 'error';
return (
<span
className={`connector-logo size-${size} state-${state}${showImage ? '' : ' is-fallback'}`}
data-palette={palette}
aria-hidden="true"
>
{showImage ? (
<img
key={url}
ref={imageRef}
className="connector-logo-img"
src={url}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
draggable={false}
onLoad={() => setState('loaded')}
onError={() => setState('error')}
/>
) : null}
{/* Fallback tile is always rendered underneath. While the image is
pending it shows as a soft skeleton; if the image errors we keep
the fallback visible and the image is unmounted so no broken-icon
chrome can leak through. Once the image resolves it covers the
fallback completely. */}
<span className="connector-logo-fallback">{initials}</span>
</span>
);
}
function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]): ConnectorDetail[] {
if (current.length === 0) return incoming;
const incomingById = new Map(incoming.map((connector) => [connector.id, connector]));
@ -237,7 +76,11 @@ function loadConnectorAuthorizationPending(): ConnectorAuthorizationPendingState
if (!connectorId) continue;
if (state && typeof state === 'object' && !Array.isArray(state)) {
const expiresAt = (state as Record<string, unknown>).expiresAt;
pending[connectorId] = typeof expiresAt === 'string' && expiresAt.trim() ? { expiresAt } : {};
const redirectUrl = (state as Record<string, unknown>).redirectUrl;
pending[connectorId] = {
...(typeof expiresAt === 'string' && expiresAt.trim() ? { expiresAt } : {}),
...(typeof redirectUrl === 'string' && redirectUrl.trim() ? { redirectUrl } : {}),
};
} else {
pending[connectorId] = {};
}
@ -269,7 +112,10 @@ export function pruneConnectorAuthorizationPending(
for (const [connectorId, state] of Object.entries(pending)) {
const expiresAtMs = state.expiresAt ? Date.parse(state.expiresAt) : Number.NaN;
if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) continue;
next[connectorId] = state.expiresAt ? { expiresAt: state.expiresAt } : {};
next[connectorId] = {
...(state.expiresAt ? { expiresAt: state.expiresAt } : {}),
...(state.redirectUrl ? { redirectUrl: state.redirectUrl } : {}),
};
}
return next;
}
@ -282,7 +128,10 @@ export function updateConnectorAuthorizationPendingFromConnectResponse(
const connectorId = response.connector.id;
const next = { ...pending };
if (response.auth?.kind === 'redirect_required' || response.auth?.kind === 'pending') {
next[connectorId] = response.auth.expiresAt ? { expiresAt: response.auth.expiresAt } : {};
next[connectorId] = {
...(response.auth.expiresAt ? { expiresAt: response.auth.expiresAt } : {}),
...(response.auth.redirectUrl ? { redirectUrl: response.auth.redirectUrl } : {}),
};
return pruneConnectorAuthorizationPending(next, nowMs);
}
delete next[connectorId];
@ -1046,6 +895,7 @@ export function ConnectorsBrowser({
: null
}
authorizationPending={connectorAuthorizationPending[connector.id]}
authorizationCancelFailed={connectorAuthorizationCancelFailed[connector.id] === true}
toolsLoading={toolsLoading}
toolsLoaded={toolsLoaded}
logoTheme={logoTheme}
@ -1111,6 +961,7 @@ function ConnectorCard({
disabled = false,
pendingAction,
authorizationPending,
authorizationCancelFailed,
toolsLoading: _toolsLoading,
toolsLoaded,
logoTheme,
@ -1123,6 +974,7 @@ function ConnectorCard({
disabled?: boolean;
pendingAction: 'connect' | 'disconnect' | null;
authorizationPending?: ConnectorAuthorizationPending;
authorizationCancelFailed: boolean;
toolsLoading: boolean;
toolsLoaded: boolean;
logoTheme: 'light' | 'dark';
@ -1160,6 +1012,12 @@ function ConnectorCard({
event.stopPropagation();
}
function continueAuthorization(event: SyntheticEvent) {
stop(event);
if (!authorizationPending?.redirectUrl) return;
void openExternalUrl(authorizationPending.redirectUrl);
}
return (
<article
className={`connector-card status-${connector.status}${disabled ? ' is-locked' : ''}`}
@ -1285,6 +1143,21 @@ function ConnectorCard({
) : null}
</div>
</div>
{authorizationCancelFailed ? (
<p className="connector-authorization-hint connector-authorization-error" role="alert">
{AUTHORIZATION_CANCEL_FAILED_MESSAGE}
</p>
) : null}
{isAuthorizationPending && authorizationPending.redirectUrl ? (
<button
type="button"
className="connector-authorization-link"
title={t('connectors.authorizationPendingHint')}
onClick={continueAuthorization}
>
{CONNECTOR_AUTH_CONTINUE_LABEL}
</button>
) : null}
</article>
);
}
@ -1364,6 +1237,12 @@ function ConnectorDetailDrawer({
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const categoryLabel = connectorCategoryLabel(connector.category, t);
function continueAuthorization(event: SyntheticEvent) {
event.stopPropagation();
if (!authorizationPending?.redirectUrl) return;
void openExternalUrl(authorizationPending.redirectUrl);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
@ -1439,9 +1318,20 @@ function ConnectorDetailDrawer({
<h3 className="connector-drawer-section-title">{t('connectors.aboutLabel')}</h3>
<p className="connector-drawer-description">{connector.description}</p>
{isAuthorizationPending ? (
<p className="connector-authorization-hint" role="status">
{t('connectors.authorizationPendingHint')}
</p>
<div className="connector-authorization-block" role="status">
<p className="connector-authorization-hint">
{t('connectors.authorizationPendingHint')}
</p>
{authorizationPending.redirectUrl ? (
<button
type="button"
className="connector-authorization-link"
onClick={continueAuthorization}
>
{CONNECTOR_AUTH_CONTINUE_LABEL}
</button>
) : null}
</div>
) : null}
</section>
) : null}

View file

@ -7,18 +7,17 @@
// feature, it's "the same provider/CLI as chat, with a different (and
// usually cheaper) model". So the picker is a single field that
// borrows the surrounding chat picker's protocol, key, base URL, and
// (for Azure) api-version automatically. The user only chooses the
// model id.
// (for Azure) api-version automatically in BYOK mode; in Local CLI
// mode the default path follows the selected CLI itself.
//
// Three render branches:
// - "Same as chat" (default): clears the override on the daemon —
// auto-pick chooses a fast default on the chat protocol's key.
// - A suggested model: stores { provider: chatProtocol, model, ... }
// so the daemon calls the chat protocol's endpoint with the
// reduced model. In CLI mode the provider is *derived* from the
// picked model id (claude-* → anthropic, gemini-* → google,
// everything else → openai) since the CLI itself isn't an API
// provider.
// reduced model. In CLI mode the provider is derived from the
// selected agent/model only as metadata; the daemon can still run
// the supported local CLI runner for "same as chat".
// - "Custom..." sentinel: opens a free-text input. Same persistence
// as the suggested branch.
//
@ -121,6 +120,30 @@ function chatProtocolFromAgent(
return null;
}
function cliAgentLabel(agentId: string | null | undefined): string | null {
if (!agentId) return null;
const id = agentId.trim().toLowerCase();
const labels: Record<string, string> = {
claude: 'Claude Code',
codex: 'Codex CLI',
gemini: 'Gemini CLI',
opencode: 'OpenCode',
qwen: 'Qwen Code',
qoder: 'Qoder CLI',
copilot: 'GitHub Copilot CLI',
pi: 'Pi',
kiro: 'Kiro CLI',
kilo: 'Kilo',
vibe: 'Mistral Vibe CLI',
deepseek: 'DeepSeek TUI',
kimi: 'Kimi',
hermes: 'Hermes',
devin: 'Devin',
'cursor-agent': 'Cursor Agent',
};
return labels[id] ?? agentId;
}
async function fetchMemoryExtraction(): Promise<MemoryExtractionMaskedConfig | null> {
try {
const resp = await fetch('/api/memory');
@ -187,14 +210,15 @@ export function MemoryModelInline({
return () => clearTimeout(id);
}, [flash]);
// The protocol the daemon will actually call when extraction fires.
// BYOK: whatever protocol the chat picker has selected. CLI: derived
// from the agent id (claude → anthropic, codex → openai, …) so the
// memory dropdown can label its default with the real provider
// instead of the misleading openai-fallback that the daemon used to
// land on for Claude Code users.
// The protocol family used for metadata and explicit model overrides.
// BYOK: whatever protocol the chat picker has selected. CLI:
// derived from the agent id (claude → anthropic, codex → openai,
// …), while the "Same as chat" default can still run the selected
// local CLI directly on daemon-supported adapters.
const effectiveChatProtocol: MemoryExtractionProvider | null =
mode === 'api' ? apiProtocol : chatProtocolFromAgent(cliAgentId);
const sameAsChatCliLabel =
mode === 'daemon' ? cliAgentLabel(cliAgentId) : null;
const modelOptions = useMemo<readonly string[]>(() => {
if (mode === 'api') return SUGGESTED_MODELS_BY_PROTOCOL[apiProtocol];
@ -215,15 +239,10 @@ export function MemoryModelInline({
// - BYOK: provider + key + baseUrl + apiVersion all come from the
// surrounding chat config so the daemon can hit the API without
// a second round-trip through the user.
// - CLI: there's no chat key to borrow. We derive a provider —
// preferring the agent-id mapping (claude → anthropic) over
// model-id pattern matching (claude-haiku-4-5 also says
// anthropic, but `default` doesn't say anything) — and let the
// daemon's pickProvider fall back to env vars or the media-
// config OpenAI key. In practice this means CLI users still
// need an env-var or media-config key for memory extraction to
// fire — exactly the behaviour they had before this picker
// existed; the picker just lets them pin the model.
// - CLI: there's no browser-side chat key to borrow. We derive a
// provider for metadata and for unsupported CLI adapters, while
// the daemon can run supported Local CLIs directly when the
// picker is on "Same as chat".
const buildOverride = useCallback(
(modelId: string): MemoryExtractionConfigShape => {
const trimmedModel = modelId.trim();
@ -407,7 +426,11 @@ export function MemoryModelInline({
onChange={(e) => void onSelectChange(e.target.value)}
>
<option value={SAME_AS_CHAT_SENTINEL}>
{effectiveChatProtocol
{sameAsChatCliLabel
? t('settings.memoryModelInlineSameAsChatWithModel', {
model: sameAsChatCliLabel,
})
: effectiveChatProtocol
? t('settings.memoryModelInlineSameAsChatWithProvider', {
provider: effectiveChatProtocol,
})

File diff suppressed because it is too large Load diff

View file

@ -104,6 +104,7 @@ import {
export type SettingsSection =
| 'execution'
| 'instructions'
| 'media'
| 'composio'
| 'orbit'
@ -798,6 +799,16 @@ export function SettingsDialog({
// stale result be ignored when it returns; the button stays disabled so a
// new smoke test cannot overlap the old one.
const agentChoiceForTest = cfg.agentModels?.[cfg.agentId ?? ''];
const selectedMemoryChatAgent =
cfg.mode === 'daemon' && cfg.agentId
? agents.find((agent) => agent.id === cfg.agentId) ?? null
: null;
const selectedMemoryChatModel =
cfg.mode === 'daemon' && cfg.agentId
? cfg.agentModels?.[cfg.agentId]?.model
?? selectedMemoryChatAgent?.models?.[0]?.id
?? null
: null;
useEffect(() => {
agentTestRevisionRef.current += 1;
setAgentTestState((state) =>
@ -1471,6 +1482,10 @@ export function SettingsDialog({
// not twice (heading + tab).
const sectionHeader: Record<SettingsSection, { title: string; subtitle: string }> = {
execution: { title: t('settings.title'), subtitle: t('settings.subtitle') },
instructions: {
title: 'Instructions / Rules',
subtitle: 'Fixed behavior the assistant should follow',
},
media: { title: t('settings.mediaProviders'), subtitle: t('settings.mediaProvidersHint') },
composio: { title: t('connectors.title'), subtitle: t('connectors.subtitle') },
orbit: { title: t('settings.orbit.title'), subtitle: t('settings.orbit.lede') },
@ -1590,6 +1605,17 @@ export function SettingsDialog({
<small>{`${t('settings.localCli')} / ${t('settings.modeApiMeta')}`}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'instructions' ? ' active' : ''}`}
onClick={() => setActiveSection('instructions')}
>
<Icon name="edit" size={18} />
<span>
<strong>Instructions / Rules</strong>
<small>Fixed assistant behavior</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'memory' ? ' active' : ''}`}
@ -2764,26 +2790,45 @@ export function SettingsDialog({
<DesignSystemsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'memory' ? (
<>
<section className="settings-section settings-section-card">
<div className="section-head">
{activeSection === 'instructions' ? (
<section className="settings-section settings-section-card instructions-rules-section">
<div className="memory-field-block instructions-rules-card">
<div className="memory-block-head">
<span className="memory-block-icon">
<Icon name="sliders" size={15} />
</span>
<div>
<h3>{t('settings.customInstructionsTitle')}</h3>
<p className="hint">{t('settings.customInstructionsHint')}</p>
<h4>{t('settings.customInstructionsTitle')}</h4>
<p className="hint">
Fixed instructions OpenDesign follows in every chat. These are
not saved memories; use Memory for facts, preferences, and
project context.
</p>
</div>
</div>
<textarea
className="custom-instructions-input"
rows={3}
className="custom-instructions-input memory-global-rules-input instructions-rules-input"
rows={5}
maxLength={5000}
placeholder={t('settings.customInstructionsPlaceholder')}
value={cfg.customInstructions ?? ''}
onChange={(e) => setCfg({ ...cfg, customInstructions: e.target.value || undefined })}
onChange={(event) =>
setCfg({
...cfg,
customInstructions: event.target.value || undefined,
})
}
/>
</section>
<MemorySection />
</>
</div>
</section>
) : null}
{activeSection === 'memory' ? (
<MemorySection
onOpenConnectors={() => setActiveSection('composio')}
chatAgentId={cfg.mode === 'daemon' ? cfg.agentId ?? null : null}
chatModel={selectedMemoryChatModel}
/>
) : null}
{activeSection === 'privacy' ? (

View file

@ -1920,11 +1920,11 @@ export const en: Dict = {
'settings.libraryToggleLabel': 'Toggle',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.customInstructionsTitle': 'Custom instructions',
'settings.customInstructionsHint': 'Standing rules the assistant follows in every chat.',
'settings.memoryHint': 'Saved facts and context for future chats',
'settings.customInstructionsTitle': 'Global rules',
'settings.customInstructionsHint': 'Fixed instructions OpenDesign should follow in every chat. Save facts, preferences, and project context as memories.',
'settings.customInstructionsPlaceholder': 'e.g. "Always use TypeScript. Prefer functional components. Keep responses concise."',
'settings.memoryDescription': 'Auto-extracted facts the assistant recalls in every chat.',
'settings.memoryDescription': 'Control whether saved facts, preferences, and project context are reused in future chats.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
@ -2079,8 +2079,8 @@ export const en: Dict = {
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. Uses an env-var or media-providers key for the auto-picked provider.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Calls {provider} with your env-var or media-providers key.',
'settings.memoryModelInlineHintCli': 'Optional. Uses the selected Local CLI when supported; choose a model only to override the default.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Uses the selected Local CLI when supported; {provider} is only the fallback provider family.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key — pick a cheaper model to save cost.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',

View file

@ -1886,11 +1886,11 @@ export const zhCN: Dict = {
'settings.libraryToggleLabel': '切换',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': '记忆',
'settings.memoryHint': '从对话中自动沉淀的个性化信息',
'settings.customInstructionsTitle': '自定义指令',
'settings.customInstructionsHint': '应用于每个项目的持久指令。用于设定模型始终应遵循的偏好。',
'settings.memoryHint': '后续对话可复用的事实和上下文',
'settings.customInstructionsTitle': '全局规则',
'settings.customInstructionsHint': 'OpenDesign 每次对话都应遵守的固定指令。事实、偏好和项目上下文请保存为记忆。',
'settings.customInstructionsPlaceholder': '例如:"始终使用 TypeScript。优先使用函数式组件。保持回复简洁。"',
'settings.memoryDescription': '自动从聊天中提取出的关于你的偏好和上下文的事实,以 Markdown 文件形式保存,并自动注入到每次对话中。',
'settings.memoryDescription': '控制已保存的事实、偏好和项目上下文是否会在后续对话中复用。',
'settings.memoryEnabled': '已启用',
'settings.memoryDisabled': '已关闭',
'settings.memoryEnableLabel': '启用记忆注入',
@ -1982,8 +1982,8 @@ export const zhCN: Dict = {
'settings.memoryModelInlineSameAsChat': '与聊天一致',
'settings.memoryModelInlineSameAsChatWithModel': '与聊天一致({model}',
'settings.memoryModelInlineSameAsChatWithProvider': '与聊天一致({provider}',
'settings.memoryModelInlineHintCli': '可选。Memory 提取仍然使用环境变量或媒体设置里的 API key 调用对应供应商;这里选模型只是替换自动挑选的默认值。',
'settings.memoryModelInlineHintCliConstrained': '可选。Memory 将调用 {provider};需要对应的环境变量或媒体设置里的 API key或在下面选一个模型来覆盖。',
'settings.memoryModelInlineHintCli': '可选。支持的情况下会直接使用当前 Local CLI这里只需要在想覆盖默认模型时选择。',
'settings.memoryModelInlineHintCliConstrained': '可选。支持时会直接使用当前 Local CLI{provider} 只作为不支持 CLI 时的备用供应商。',
'settings.memoryModelInlineHintByok': '可选。复用你聊天用的 API key在同供应商上换一个通常更便宜的模型来跑后台 memory 提取。',
'settings.memoryModelInlineFlashSaved': '已保存',
'settings.memoryModelInlineFlashCleared': '已清除',

View file

@ -1549,8 +1549,8 @@ export const zhTW: Dict = {
'settings.memoryModelInlineSameAsChat': '與聊天一致',
'settings.memoryModelInlineSameAsChatWithModel': '與聊天一致({model}',
'settings.memoryModelInlineSameAsChatWithProvider': '與聊天一致({provider}',
'settings.memoryModelInlineHintCli': '可選。Memory 擷取仍會使用環境變數或媒體設定裡的 API key 呼叫對應供應商;在這裡選模型只是覆寫自動挑選的預設值。',
'settings.memoryModelInlineHintCliConstrained': '可選。Memory 會呼叫 {provider};需要對應的環境變數或媒體設定裡的 API key或在下方選一個模型覆寫。',
'settings.memoryModelInlineHintCli': '可選。支援時會直接使用目前的 Local CLI只在想覆寫預設模型時選擇。',
'settings.memoryModelInlineHintCliConstrained': '可選。支援時會直接使用目前的 Local CLI{provider} 只作為不支援 CLI 時的備用供應商。',
'settings.memoryModelInlineHintByok': '可選。沿用你聊天用的 API key在同供應商上換成通常更便宜的模型跑背景 memory 擷取。',
'settings.memoryModelInlineFlashSaved': '已儲存',
'settings.memoryModelInlineFlashCleared': '已清除',

File diff suppressed because it is too large Load diff

View file

@ -744,6 +744,32 @@ function popupBlockedMessage(): string {
return 'Popup blocked. Allow popups for Open Design and try again.';
}
export async function openExternalUrl(url: string): Promise<boolean> {
if (isOpenDesignHostAvailable()) {
const opened = await openHostExternalUrl(url);
if (opened.ok) return true;
}
try {
const resp = await fetch('/api/system/open-external', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (resp.ok) {
const json = (await resp.json().catch(() => null)) as { ok?: unknown } | null;
if (json?.ok === true) return true;
}
} catch {
// Fall through to current-tab navigation below.
}
try {
window.location.assign(url);
} catch {
return false;
}
return false;
}
async function decodeConnectorError(resp: Response): Promise<string> {
try {
const payload = (await resp.json()) as { error?: { message?: string } } | null;
@ -795,14 +821,11 @@ export async function connectConnector(connectorId: string): Promise<ConnectorAc
} else if (authWindow) {
openConnectorAuthRedirect(authWindow, json.auth.redirectUrl);
} else {
const redirected = window.open(json.auth.redirectUrl, '_blank');
if (!redirected) {
return {
connector: json.connector ?? null,
auth: json.auth,
error: popupBlockedMessage(),
};
}
// The embedded browser can block even the synchronous placeholder
// popup. Ask the local daemon to open the system browser; if that
// route is unavailable, openExternalUrl falls back to current-tab
// navigation.
await openExternalUrl(json.auth.redirectUrl);
}
} else if (json.auth?.kind === 'connected') {
renderConnectorAuthInfo(authWindow, {

View file

@ -12,6 +12,7 @@ import {
fetchConnectorDiscovery,
fetchConnectors,
fetchConnectorStatuses,
openExternalUrl,
} from '../../src/providers/registry';
vi.mock('../../src/providers/registry', async () => {
@ -27,6 +28,7 @@ vi.mock('../../src/providers/registry', async () => {
fetchConnectorDiscovery: vi.fn(),
fetchConnectors: vi.fn(),
fetchConnectorStatuses: vi.fn(),
openExternalUrl: vi.fn(),
};
});
@ -66,9 +68,11 @@ describe('ConnectorsBrowser', () => {
vi.mocked(fetchConnectorDetail).mockReset();
vi.mocked(fetchConnectorDiscovery).mockReset();
vi.mocked(fetchConnectorStatuses).mockReset();
vi.mocked(openExternalUrl).mockReset();
vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null);
vi.mocked(connectConnector).mockResolvedValue({ connector: null });
vi.mocked(fetchConnectorDetail).mockResolvedValue(null);
vi.mocked(openExternalUrl).mockResolvedValue(true);
window.sessionStorage.clear();
});
@ -425,6 +429,11 @@ describe('ConnectorsBrowser', () => {
await screen.findByText('GitHub');
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByRole('button', { name: 'Cancel' });
const authorizationButton = screen.getByRole('button', { name: 'Continue in browser' });
fireEvent.click(authorizationButton);
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(
'https://example.com/oauth',
));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
@ -812,7 +821,7 @@ describe('ConnectorsBrowser', () => {
fireEvent(window, new Event('focus'));
await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github'));
expect(await screen.findByText("Couldn't cancel authorization. Try again.")).toBeTruthy();
expect(await screen.findAllByText("Couldn't cancel authorization. Try again.")).toHaveLength(2);
vi.useRealTimers();
});

View file

@ -256,7 +256,12 @@ describe('connector authorization pending state', () => {
},
}, nowMs);
expect(pending).toEqual({ exist: { expiresAt: future } });
expect(pending).toEqual({
exist: {
expiresAt: future,
redirectUrl: 'https://example.com/oauth',
},
});
});
it('keeps pending state while status polling still reports available', () => {

View file

@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemorySection } from '../../src/components/MemorySection';
@ -32,14 +33,20 @@ class StubEventSource {
close() {}
}
function renderMemorySection() {
render(
function renderMemorySection(props: Partial<ComponentProps<typeof MemorySection>> = {}) {
return render(
<I18nProvider initial="en">
<MemorySection />
<MemorySection {...props} />
</I18nProvider>,
);
}
async function findMemoryIndexTextarea() {
const indexDetails = (await screen.findByText('MEMORY.md (index)'))
.closest('details') as HTMLElement;
return within(indexDetails).getByRole('textbox') as HTMLTextAreaElement;
}
describe('MemorySection', () => {
afterEach(() => {
cleanup();
@ -57,6 +64,7 @@ describe('MemorySection', () => {
} else {
delete (HTMLElement.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
window.sessionStorage.clear();
});
it('shows the no-provider banner when the latest extraction skipped for missing credentials', async () => {
@ -310,7 +318,7 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
const indexArea = screen.getByRole('textbox') as HTMLTextAreaElement;
const indexArea = await findMemoryIndexTextarea();
fireEvent.change(indexArea, {
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
});
@ -379,11 +387,457 @@ describe('MemorySection', () => {
expect(document.activeElement).toBe(nameInput);
});
it('uses the same expandable affordance for extraction history and memory index', async () => {
it('renders saved records in a separate memory records section', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [
{
id: 'user_ui_preferences',
name: 'UI preferences',
description: 'Initial preference',
type: 'user',
updatedAt: Date.now(),
},
{
id: 'feedback_density',
name: 'Feedback density',
description: 'Prefer compact review cards',
type: 'feedback',
updatedAt: Date.now(),
},
{
id: 'project_brand_rule',
name: 'Project brand rule',
description: 'Use the project brand kit',
type: 'project',
updatedAt: Date.now(),
},
],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({
extractions: [
{
id: 'ex-1',
phase: 'success',
kind: 'llm',
startedAt: Date.now(),
finishedAt: Date.now() + 1200,
userMessagePreview: 'Remember I prefer dark mode',
writtenCount: 1,
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const savedRow = await screen.findByText('UI preferences');
const extractionRow = await screen.findByText('Remember I prefer dark mode');
const indexSummary = screen.getByText('MEMORY.md (index)')
.closest('summary') as HTMLElement;
expect(savedRow.closest('.library-card')).toBeTruthy();
expect(savedRow.closest('.memory-records-section')).toBeTruthy();
expect(screen.getByRole('tab', { name: 'Add manually' }).closest('.memory-records-section')).toBeNull();
expect(extractionRow.closest('.library-card')?.className).toContain(
'memory-extraction-card',
);
expect(indexSummary.closest('.memory-advanced-section')).toBeTruthy();
expect(indexSummary.closest('.memory-records-section')).toBeNull();
expect(screen.queryByText('Extraction history')).toBeNull();
expect(indexSummary.className).toContain('memory-details-summary');
expect(document.querySelector('.memory-records-section .library-group-title')).toBeNull();
});
it('suggests and saves memory from selected connected apps', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
let entries: Array<{
id: string;
name: string;
description: string;
type: string;
updatedAt: number;
}> = [];
const authWindow = {
document: {
title: '',
body: { innerHTML: '' },
},
location: { replace: vi.fn() },
close: vi.fn(),
};
vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window);
const suggestionBodies: unknown[] = [];
const createBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries,
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/connectors/discovery?hydrateTools=false') {
return new Response(JSON.stringify({
connectors: [
{
id: 'notion',
name: 'Notion',
provider: 'composio',
category: 'Productivity',
status: 'connected',
accountLabel: 'Product wiki',
tools: [{ name: 'notion.notion_search' }],
},
{
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
status: 'available',
tools: [],
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') {
return new Response(JSON.stringify({
results: {
github: { status: 'ready', authConfigId: 'ac_github' },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/github/connect' && init?.method === 'POST') {
return new Response(JSON.stringify({
connector: {
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
status: 'available',
tools: [],
},
auth: {
kind: 'redirect_required',
redirectUrl: 'https://example.com/github-oauth',
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/status') {
return new Response(JSON.stringify({
statuses: {
github: { status: 'available' },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
suggestionBodies.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({
suggestions: [
{
id: 'project_memory_context_1',
name: 'Memory context',
description: 'Connector-derived context',
type: 'project',
body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.',
source: {
kind: 'connector',
connectorId: 'notion',
connectorName: 'Notion',
accountLabel: 'Product wiki',
toolName: 'notion.notion_search',
toolTitle: 'Search Notion',
},
},
],
attemptedLLM: true,
contextBytes: 128,
connectors: [
{
connectorId: 'notion',
connectorName: 'Notion',
accountLabel: 'Product wiki',
status: 'succeeded',
toolName: 'notion.notion_search',
toolTitle: 'Search Notion',
summary: 'Found product memory notes.',
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/project_memory_context_1' && init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
createBodies.push(body);
entries = [
{
id: 'project_memory_context_1',
name: body.name,
description: body.description,
type: body.type,
updatedAt: Date.now(),
},
];
return new Response(JSON.stringify({
entry: {
id: 'project_memory_context_1',
name: body.name,
description: body.description,
type: body.type,
body: body.body,
updatedAt: Date.now(),
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection({
chatAgentId: 'opencode',
chatModel: 'openai/gpt-5',
});
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
expect(await screen.findByText('Product wiki')).toBeTruthy();
const notionRow = document.querySelector('[data-memory-connector-id="notion"]') as HTMLElement;
const githubRow = document.querySelector('[data-memory-connector-id="github"]') as HTMLElement;
expect(within(notionRow).getByText('Select')).toBeTruthy();
expect(within(notionRow).queryByText('Connected')).toBeNull();
const connectGitHubButton = within(githubRow).getByRole('button', { name: 'Connect GitHub' });
expect(connectGitHubButton).toBeTruthy();
expect(within(githubRow).queryByText('Not connected')).toBeNull();
expect(screen.queryByText('Connect first')).toBeNull();
const extractButton = await screen.findByRole('button', { name: /Select apps to scan/i });
await waitFor(() => {
expect((extractButton as HTMLButtonElement).disabled).toBe(true);
});
expect(screen.getByText('Selected 0 of 1 connected app.')).toBeTruthy();
fireEvent.click(connectGitHubButton);
await waitFor(() => {
expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth');
});
expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
expect(within(githubRow).queryByText('Select')).toBeNull();
expect((within(githubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByLabelText('Use Notion for memory extraction'));
await waitFor(() => {
expect((extractButton as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.getByText('Selected')).toBeTruthy();
fireEvent.click(extractButton);
await waitFor(() => {
expect(screen.getByText(/Found 1 suggested memory from 1 app/)).toBeTruthy();
});
expect(screen.getByText('Last scan')).toBeTruthy();
expect(screen.getByText('128 B read')).toBeTruthy();
expect(screen.getByText('Read Notion')).toBeTruthy();
expect(screen.getByText(/Search Notion · Found product memory notes/)).toBeTruthy();
expect(screen.getByText('Suggested memories')).toBeTruthy();
expect(suggestionBodies).toEqual([{
connectorIds: ['notion'],
chatAgentId: 'opencode',
chatModel: 'openai/gpt-5',
}]);
expect(screen.getByText('Memory context')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Save selected/i }));
await waitFor(() => {
expect(screen.getByText(/Saved 1 memory from connected apps/)).toBeTruthy();
});
expect(createBodies).toEqual([
{
id: 'project_memory_context_1',
name: 'Memory context',
description: 'Connector-derived context',
type: 'project',
body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.',
},
]);
});
it('saves same-name connector suggestions as distinct memory records', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
let entries: Array<{
id: string;
name: string;
description: string;
type: string;
updatedAt: number;
}> = [];
const putBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries,
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/connectors/discovery?hydrateTools=false') {
return new Response(JSON.stringify({
connectors: [
{
id: 'notion',
name: 'Notion',
provider: 'composio',
category: 'Productivity',
status: 'connected',
accountLabel: 'Product wiki',
tools: [{ name: 'notion.notion_search' }],
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/status') {
return new Response(JSON.stringify({
statuses: {
notion: { status: 'connected', accountLabel: 'Product wiki' },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
return new Response(JSON.stringify({
suggestions: [
{
id: 'project_comfyui_logo_1',
name: 'ComfyUI logo',
description: 'Use the black logo in product visuals.',
type: 'project',
body: 'ComfyUI design work should use the black logo.',
},
{
id: 'project_comfyui_logo_2',
name: 'ComfyUI logo',
description: 'Keep the visual style dark.',
type: 'project',
body: 'ComfyUI related layouts should prefer a dark visual style.',
},
],
attemptedLLM: true,
contextBytes: 256,
connectors: [
{
connectorId: 'notion',
connectorName: 'Notion',
accountLabel: 'Product wiki',
status: 'succeeded',
toolName: 'notion.notion_search',
toolTitle: 'Search Notion',
summary: 'Read page content: 设计思路.',
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url.startsWith('/api/memory/project_comfyui_logo_') && init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
putBodies.push({ url, body });
entries = [
...entries.filter((entry) => entry.id !== body.id),
{
id: body.id,
name: body.name,
description: body.description,
type: body.type,
updatedAt: Date.now(),
},
];
return new Response(JSON.stringify({
entry: {
id: body.id,
name: body.name,
description: body.description,
type: body.type,
body: body.body,
updatedAt: Date.now(),
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction'));
fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i }));
await waitFor(() => {
expect(screen.getByText(/Found 2 suggested memories from 1 app/)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Save selected/i }));
await waitFor(() => {
expect(screen.getByText(/Saved 2 memories from connected apps/)).toBeTruthy();
});
expect(putBodies).toEqual([
expect.objectContaining({
url: '/api/memory/project_comfyui_logo_1',
body: expect.objectContaining({ id: 'project_comfyui_logo_1' }),
}),
expect.objectContaining({
url: '/api/memory/project_comfyui_logo_2',
body: expect.objectContaining({ id: 'project_comfyui_logo_2' }),
}),
]);
expect(entries.map((entry) => entry.id).sort()).toEqual([
'project_comfyui_logo_1',
'project_comfyui_logo_2',
]);
});
it('keeps connector authorization pending after the memory panel remounts', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const authWindow = {
document: {
title: '',
body: { innerHTML: '' },
},
location: { replace: vi.fn() },
close: vi.fn(),
};
vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
@ -398,18 +852,154 @@ describe('MemorySection', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/connectors/discovery?hydrateTools=false') {
return new Response(JSON.stringify({
connectors: [
{
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
status: 'available',
tools: [],
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/status') {
return new Response(JSON.stringify({
statuses: {
github: { status: 'available' },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') {
return new Response(JSON.stringify({
results: {
github: { status: 'ready', authConfigId: 'ac_github' },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/github/connect' && init?.method === 'POST') {
return new Response(JSON.stringify({
connector: {
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
status: 'available',
tools: [],
},
auth: {
kind: 'redirect_required',
redirectUrl: 'https://example.com/github-oauth',
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
const first = renderMemorySection();
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
const githubRow = await waitFor(() => {
const row = document.querySelector('[data-memory-connector-id="github"]');
expect(row).toBeTruthy();
return row as HTMLElement;
});
fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' }));
await waitFor(() => {
expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth');
});
expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
expect(within(githubRow).queryByText('Select')).toBeNull();
first.unmount();
renderMemorySection();
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
const remountedGithubRow = await waitFor(() => {
const row = document.querySelector('[data-memory-connector-id="github"]');
expect(row).toBeTruthy();
return row as HTMLElement;
});
expect(within(remountedGithubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
expect(within(remountedGithubRow).queryByText('Select')).toBeNull();
expect((within(remountedGithubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
});
it('shows connector read failures instead of a generic empty state', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/connectors/discovery?hydrateTools=false') {
return new Response(JSON.stringify({
connectors: [
{
id: 'notion',
name: 'Notion',
provider: 'composio',
category: 'Productivity',
status: 'connected',
accountLabel: 'Product wiki',
tools: [{ name: 'notion.notion_search' }],
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
return new Response(JSON.stringify({
suggestions: [],
attemptedLLM: false,
contextBytes: 0,
connectors: [
{
connectorId: 'notion',
connectorName: 'Notion',
accountLabel: 'Product wiki',
status: 'failed',
summary: 'No safe connector read completed.',
error: 'Tool NOTION_SEARCH not found',
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const extractionSummary = (await screen.findByText('Extraction history'))
.closest('summary') as HTMLElement;
const indexSummary = screen.getByText('MEMORY.md (index)')
.closest('summary') as HTMLElement;
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction'));
fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i }));
expect(extractionSummary.className).toContain('memory-details-summary');
expect(indexSummary.className).toContain('memory-details-summary');
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain("Couldn't read Notion.");
expect(alert.textContent).toContain('Tool NOTION_SEARCH not found');
expect(screen.getByText('Last scan')).toBeTruthy();
expect(screen.getByText('No data read')).toBeTruthy();
expect(screen.getByText('Could not read Notion')).toBeTruthy();
expect(screen.getAllByText(/Tool NOTION_SEARCH not found/).length).toBeGreaterThan(0);
expect(
screen.queryByText(/could not read useful content from the selected app/i),
).toBeNull();
});
it('clears extraction history after clicking Clear', async () => {
@ -456,13 +1046,12 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
await waitFor(() => {
expect(screen.getByText('No extractions yet. The next chat turn will populate this list.')).toBeTruthy();
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
});
expect(deletedUrls).toEqual(['/api/memory/extractions']);
expect(confirmSpy).toHaveBeenCalledTimes(1);
@ -513,7 +1102,6 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
@ -818,7 +1406,7 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
const indexArea = screen.getByRole('textbox') as HTMLTextAreaElement;
const indexArea = await findMemoryIndexTextarea();
fireEvent.change(indexArea, {
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
});
@ -827,7 +1415,7 @@ describe('MemorySection', () => {
await waitFor(() => {
expect(screen.getByText(/Unsaved changes/i)).toBeTruthy();
});
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toContain('- New bullet');
expect(indexArea.value).toContain('- New bullet');
expect(screen.queryByText('✓ Index saved')).toBeNull();
});
@ -880,11 +1468,11 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
expect(screen.getByText('No durable memory in this turn')).toBeTruthy();
const row = screen.getByText('Remember I prefer dark mode').closest('li') as HTMLElement;
const row = screen.getByText('Remember I prefer dark mode')
.closest('.library-card') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
await waitFor(() => {
@ -927,10 +1515,9 @@ describe('MemorySection', () => {
}) as typeof fetch;
renderMemorySection();
expect(await screen.findByText('UI preferences')).toBeTruthy();
fireEvent.click(await screen.findByText('Extraction history'));
expect(screen.getByText('UI preferences')).toBeTruthy();
expect(screen.getByText('No extractions yet. The next chat turn will populate this list.')).toBeTruthy();
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
const es = StubEventSource.instances[0]!;
es.emit('extraction', {
@ -957,13 +1544,14 @@ describe('MemorySection', () => {
},
];
es.emit('change', { kind: 'upsert', id: 'project_brief' });
fireEvent.click(screen.getByRole('tab', { name: 'Add manually' }));
await waitFor(() => {
expect(screen.getByText('Project brief')).toBeTruthy();
});
});
it('renders failed extraction rows with the error details', async () => {
it('renders failed extraction rows with user-facing error details', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
@ -1000,12 +1588,129 @@ describe('MemorySection', () => {
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember my dashboard preference')).toBeTruthy();
expect(screen.getByText('provider returned 429 quota exceeded')).toBeTruthy();
expect(screen.getByText('Memory model quota or rate limit hit')).toBeTruthy();
expect(screen.getByText('Try again later or switch the Memory extraction model.')).toBeTruthy();
expect(screen.queryByText('provider returned 429 quota exceeded')).toBeNull();
expect(screen.getByText('Failed')).toBeTruthy();
});
it('renders connector model authentication failures without raw provider JSON', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({
extractions: [
{
id: 'ex-connector-failed',
phase: 'failed',
kind: 'connector',
provider: {
kind: 'openai',
model: 'gpt-4o-mini',
credentialSource: 'memory-config',
},
startedAt: Date.now(),
finishedAt: Date.now() + 1300,
userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.',
error: 'openai 401: { "error": { "message": "Your authentication token has expired. Please try signing in again.", "type": "invalid_request_error", "code": "token_expired", "param": null }, "status": 401 }',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' }));
expect(await screen.findByText('Connected app scan failed')).toBeTruthy();
expect(
within(screen.getByLabelText('Connected app memory run status'))
.getByText('Connected app scan failed'),
).toBeTruthy();
expect(
screen.queryByText('Suggest durable OpenDesign memories from connected apps.'),
).toBeNull();
expect(
within(document.querySelector('.memory-unified-list') as HTMLElement)
.queryByText('Suggest durable OpenDesign memories from connected apps.'),
).toBeNull();
expect(screen.getByText('OpenAI authentication expired')).toBeTruthy();
expect(
screen.getByText('Connected apps were read, but OpenDesign could not turn that context into memory.'),
).toBeTruthy();
expect(
screen.getByText('Update the Memory extraction model key or sign in again.'),
).toBeTruthy();
expect(screen.queryByText(/token_expired/)).toBeNull();
});
it('renders Local CLI extraction failures without API-key guidance', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({
extractions: [
{
id: 'ex-cli-failed',
phase: 'failed',
kind: 'connector',
provider: {
kind: 'anthropic',
model: 'default',
credentialSource: 'chat-cli',
},
startedAt: Date.now(),
finishedAt: Date.now() + 900,
userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.',
error: 'Claude Code CLI exit 1: authentication token has expired',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' }));
expect(await screen.findByText('Claude Code authentication expired')).toBeTruthy();
expect(screen.getByLabelText('Connected app memory run status')).toBeTruthy();
expect(
screen.getByText('Sign in to the selected Local CLI or choose a different Memory model.'),
).toBeTruthy();
expect(screen.queryByText(/Update the Memory extraction model key/)).toBeNull();
});
it('renders the disabled banner when memory starts disabled', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
@ -1077,4 +1782,55 @@ describe('MemorySection', () => {
});
expect(patchBodies).toEqual([{ enabled: false }]);
});
it('toggles chat conversation learning off and persists the PATCH payload', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
chatExtractionEnabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory/config' && init?.method === 'PATCH') {
patchBodies.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({
enabled: true,
chatExtractionEnabled: false,
extraction: null,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByRole('tab', { name: 'Learn from chats' }));
const toggle = screen.getByRole('checkbox', {
name: 'Learn from chat conversations',
}) as HTMLInputElement;
expect(toggle.checked).toBe(true);
fireEvent.click(toggle);
await waitFor(() => expect(toggle.checked).toBe(false));
expect(screen.getByText('Off')).toBeTruthy();
expect(patchBodies).toEqual([{ chatExtractionEnabled: false }]);
});
});

View file

@ -822,6 +822,41 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
expect(screen.getByText(/No agents detected yet/i)).toBeTruthy();
});
it('labels the memory model default with the selected Local CLI', async () => {
const agents: AgentInfo[] = [
...availableAgents,
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '1.2.3',
models: [{ id: 'default', label: 'Default (CLI config)' }],
},
];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(JSON.stringify({}), { status: 404 });
}));
renderSettingsDialog(
{ mode: 'daemon', agentId: 'claude' },
{ agents },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*2 installed/i }));
const memoryModel = await screen.findByRole('combobox', { name: 'Memory model' }) as HTMLSelectElement;
expect(memoryModel.options[memoryModel.selectedIndex]?.textContent).toBe('Same as chat (Claude Code)');
expect(screen.getByText(/anthropic is only the fallback provider family/i)).toBeTruthy();
});
it('shows rescan loading, avoids duplicate rescans, and renders the success notice', async () => {
const nextAgents: AgentInfo[] = [
availableAgents[0]!,

View file

@ -375,9 +375,13 @@ describe('connectConnector', () => {
expect(authWindow.document.body.innerHTML).toContain('Default auth config not found for toolkit "canvas".');
});
it('returns a user-facing error when the OAuth popup is blocked', async () => {
it('opens the system browser through the daemon when the OAuth popup is blocked', async () => {
const open = vi.fn(() => null);
vi.stubGlobal('window', { open } as unknown as Window & typeof globalThis);
const assign = vi.fn();
vi.stubGlobal('window', {
open,
location: { assign },
} as unknown as Window & typeof globalThis);
const fetchMock = vi.fn(async (url: string) => {
if (url === '/api/connectors/auth-configs/prepare') {
return new Response(JSON.stringify({
@ -386,6 +390,9 @@ describe('connectConnector', () => {
},
}), { status: 200 });
}
if (url === '/api/system/open-external') {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response(JSON.stringify({
connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] },
auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' },
@ -396,9 +403,15 @@ describe('connectConnector', () => {
await expect(connectConnector('github')).resolves.toEqual({
connector: { id: 'github', name: 'GitHub', status: 'available', tools: [] },
auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth' },
error: 'Popup blocked. Allow popups for Open Design and try again.',
});
expect(open).toHaveBeenCalledTimes(2);
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith('about:blank', '_blank');
expect(assign).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith('/api/system/open-external', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com/oauth' }),
});
expect(fetchMock).not.toHaveBeenCalledWith('/api/connectors/github/authorization/cancel', {
method: 'POST',
});

View file

@ -77,6 +77,7 @@ export type TrackingTopTabId =
export type TrackingActiveSection =
| 'execution_model'
| 'instructions'
| 'media_providers'
| 'language'
| 'appearance'
@ -490,6 +491,8 @@ export function settingsSectionToTracking(
switch (section) {
case 'execution':
return 'execution_model';
case 'instructions':
return 'instructions';
case 'media':
return 'media_providers';
case 'language':

View file

@ -41,10 +41,29 @@ export interface MemoryEntry extends MemoryEntrySummary {
body: string;
}
export interface MemorySuggestion {
/** Stable id for this suggestion batch, not the final memory file id. */
id: string;
name: string;
description: string;
type: MemoryType;
body: string;
source?: {
kind: 'connector';
connectorId?: string;
connectorName?: string;
accountLabel?: string;
toolName?: string;
toolTitle?: string;
};
}
// GET /api/memory
export interface MemoryListResponse {
/** True when the daemon will inject memory into the next system prompt. */
enabled: boolean;
/** True when new chat turns may create memory suggestions/extractions. */
chatExtractionEnabled: boolean;
/** Absolute path to the memory directory (informational, for the settings UI). */
rootDir: string;
/** The MEMORY.md index body — usually a list of `- [Name](file.md) — hook` lines. */
@ -144,6 +163,7 @@ export interface UpdateMemoryIndexRequest {
// and/or override the LLM extraction provider.
export interface UpdateMemoryConfigRequest {
enabled?: boolean;
chatExtractionEnabled?: boolean;
/** Pass `null` to clear the override and fall back to auto-pick. Pass an
* object to commit a custom provider. Omit to leave unchanged. */
extraction?: MemoryExtractionConfig | null;
@ -151,6 +171,7 @@ export interface UpdateMemoryConfigRequest {
export interface MemoryConfigResponse {
enabled: boolean;
chatExtractionEnabled: boolean;
extraction: MemoryExtractionConfig | null;
}
@ -269,7 +290,7 @@ export interface MemoryChangeEvent {
count?: number;
/** Where the change came from. Useful for UX (e.g., suppress toasts on
* manual edits since the user just clicked Save themselves). */
source?: 'heuristic' | 'llm' | 'manual';
source?: 'heuristic' | 'llm' | 'manual' | 'connector';
/** Only on `kind: 'config'` — the new enabled flag. */
enabled?: boolean;
/** Unix milliseconds. */
@ -294,7 +315,49 @@ export interface MemoryChangeEvent {
/** Which extractor produced the attempt. `'llm'` is the legacy default
* for records written before this field existed. */
export type MemoryExtractionKind = 'heuristic' | 'llm';
export type MemoryExtractionKind = 'heuristic' | 'llm' | 'connector';
// POST /api/memory/connectors/suggest and /extract — read approved,
// read-only data from selected connected apps and feed the compacted result
// through the memory extractor. The daemon chooses safe read tools per
// connector; the UI only supplies connector ids and an optional search hint.
export interface ConnectorMemoryExtractionRequest {
connectorIds?: string[];
query?: string;
projectId?: string | null;
/** Current Local CLI agent selected for chat. Connector memory uses this
* to keep "Same as chat" extraction on the user's active CLI even before
* the debounced settings save reaches the daemon. */
chatAgentId?: string | null;
/** Current chat model for `chatAgentId`, forwarded with connector memory
* requests for the same reason as `chatAgentId`. */
chatModel?: string | null;
}
export interface ConnectorMemoryExtractionResult {
connectorId: string;
connectorName: string;
accountLabel?: string;
status: 'succeeded' | 'skipped' | 'failed';
toolName?: string;
toolTitle?: string;
summary: string;
error?: string;
}
export interface ConnectorMemoryExtractionResponse {
changed: MemoryEntrySummary[];
attemptedLLM: boolean;
connectors: ConnectorMemoryExtractionResult[];
contextBytes: number;
}
export interface ConnectorMemorySuggestionResponse {
suggestions: MemorySuggestion[];
attemptedLLM: boolean;
connectors: ConnectorMemoryExtractionResult[];
contextBytes: number;
}
export type MemoryExtractionPhase =
| 'running'
@ -317,6 +380,7 @@ export type MemoryExtractionPhase =
export type MemoryExtractionSkipReason =
| 'no-provider'
| 'memory-disabled'
| 'chat-disabled'
| 'empty-message'
| 'no-match';
@ -346,8 +410,15 @@ export interface MemoryExtractionRecord {
* the OpenAI key the user configured under Settings Media
* providers; `'chat-byok'` = the live BYOK chat provider/key/
* baseUrl threaded through `/api/memory/extract` for "Same as
* chat" extraction in API mode. */
credentialSource: 'memory-config' | 'env' | 'media-config' | 'chat-byok';
* chat" extraction in API mode; `'chat-cli'` = the current Local
* CLI run in background one-shot mode for "Same as chat"
* extraction in CLI mode. */
credentialSource:
| 'memory-config'
| 'env'
| 'media-config'
| 'chat-byok'
| 'chat-cli';
};
/** First ~120 chars of the user's message for display in the list. */
userMessagePreview: string;