mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add connector memory extraction flow (#2265)
This commit is contained in:
parent
a3a238247e
commit
e94663bfbd
29 changed files with 7346 additions and 961 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1355
apps/daemon/src/memory-connectors.ts
Normal file
1355
apps/daemon/src/memory-connectors.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
1155
apps/daemon/tests/memory-connectors.test.ts
Normal file
1155
apps/daemon/tests/memory-connectors.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
158
apps/web/src/components/ConnectorLogo.tsx
Normal file
158
apps/web/src/components/ConnectorLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '已清除',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]!,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue