feat(memory): auto-memory store with chat-protocol-aware extraction (#999)

* feat(memory): auto-memory store with chat-protocol-aware extraction

Markdown memory store at <dataDir>/memory/ with two extractors —
heuristic regex for explicit "remember:" / "我是 X" markers, and a
small-model LLM pass after each turn — folded into the system prompt
so cross-chat preferences, role, and ongoing-work context survive
restarts.

Settings UI:
- Memory tab lists entries, exposes a hand-edited MEMORY.md index, and
  shows an extraction history with per-attempt phase/skip/failure rows.
- Memory model picker is inline next to the chat model picker (CLI and
  BYOK) so the choice "which fast model mines facts each turn?" sits
  next to the chat-model decision instead of a separate panel. The
  picker reuses the same SUGGESTED_MODELS table and "Custom..." pattern
  the chat picker uses.

LLM extractor supports all four protocols (anthropic / openai / azure /
google); pickProvider takes the chat agent id from the chat handler
and constrains its auto-pick to the chat's protocol family — Claude
Code chats no longer surprise users by silently extracting on whatever
OpenAI key happens to be in media-config. When no matching key is
configured the attempt records as 'skipped: no-provider' instead of
quietly switching vendors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): keep hint outside <label> and disambiguate Model selectors

The inline Memory model picker wrapped its hint paragraph inside the
<label>, which made the hint's "API key" / "model" wording bleed into
the <select>'s accessible name and broke Playwright's getByLabel('API
key') / getByLabel('Model') strict-mode matching in the existing
settings-api-protocol e2e suite.

- Move the hint <p> out of the <label> in MemoryModelInline so the
  select's accessible name is just "Memory model".
- Switch the chat-Model selectors in settings-api-protocol.test.ts from
  getByLabel('Model') to getByRole('combobox', { name: 'Model', exact:
  true }) so they no longer collide with the new "Memory model" select
  that sits next to the chat Model picker.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): address review changes — BYOK wiring, MEMORY.md index, /v1, label wrapper

Addresses the four blocking review threads on PR #999.

1. MemoryModelInline accessibility (mrcfps)
   The inline picker still wrapped its select + custom input + flash +
   hint inside a single <label>, which made the select's accessible
   name absorb every text descendant — including the "API key" / "model"
   hint copy. The previous fix moved only the hint outside; the
   reviewer asked for a non-label wrapper. Switch to <div className="field">
   and associate just the short title with the controls via
   `aria-labelledby` / `aria-label`. The select's accessible name is
   now exactly "Memory model" so `getByLabel` strict-mode locators
   on the surrounding chat form stop cross-matching the memory copy.

2. Respect the hand-edited MEMORY.md index (mrcfps + codex)
   `composeMemoryBody()` was reading every *.md file in the memory
   dir, ignoring the index. Removing a `- [Name](id.md)` line had no
   effect on future prompts. Parse the index's `INDEX_LINK_RE` bullets
   and filter `listMemoryEntries()` to the linked id set, so the
   editor's "delete this line to disable injection" promise actually
   holds.

3. Versioned OpenAI-compatible base URLs (codex)
   `callOpenAI` and `callAnthropic` hard-coded `/v1` onto
   `provider.baseUrl`, breaking custom endpoints whose saved URL
   already includes `/v1` (`/v1/v1/chat/completions`). Apply the same
   conditional `appendVersionedApiPath` helper the chat proxy and
   connection-test routes already use.

4. Wire memory into BYOK / API-mode chats (mrcfps + codex)
   The previous PR's daemon-only memory hook never fired for BYOK,
   leaving the Memory tab + model picker as a no-op for that mode.
   Add the missing surface and wire it through ProjectView:
   - contracts: extend `composeSystemPrompt` with `memoryBody`,
     mirroring the daemon's local composer; add
     `MemorySystemPromptResponse` and the `attemptedLLM` flag on
     `ExtractMemoryResponse`.
   - daemon: expose `GET /api/memory/system-prompt` (returns the
     composed body) and turn `POST /api/memory/extract` into a
     two-phase endpoint — heuristic-only when only userMessage is
     supplied (pre-turn), LLM-only when assistantMessage is also
     supplied (post-turn), so the extraction-history doesn't double
     up.
   - web: ProjectView's BYOK branch now fetches the memory body
     before composing the system prompt, runs the heuristic
     extractor before the run (so "remember:" markers in this turn
     reach this turn's prompt), accumulates assistant text during
     streaming, and queues the LLM extractor on `onDone` — fire-and-
     forget so it never blocks the chat round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): re-sync BYOK memory override when chat config drifts

The inline memory-model picker captured `apiProtocol` / `chatApiKey` /
`chatBaseUrl` / `chatApiVersion` into the saved override only at the
moment the user clicked a model. If they later swapped the BYOK
protocol tab, rotated the API key, or edited the base URL in the same
settings flow, the daemon's background extractor kept calling the
*old* vendor / credential — directly contradicting the picker's
"borrows the surrounding chat picker's protocol, key, base URL, and
api-version automatically" promise.

Add a debounced effect that compares the persisted (masked) shape
against the live chat props and re-PATCHes /api/memory/config when
they drift. The masked config exposes `apiKeyTail` (last 4 chars), so
key rotation is detectable without ever round-tripping the secret
back to the browser. The 300 ms debounce coalesces the keystroke-
granularity prop updates the parent settings dialog streams during
its autosave loop, so a user editing the base URL doesn't trigger one
PATCH per character. Background re-syncs are silent — the "Saved!"
flash only fires for explicit user clicks, so the picker doesn't feel
like it's fighting them as they edit unrelated chat fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): thread BYOK chat config through /api/memory/extract default path

Leaving the BYOK memory picker on "Same as chat" still broke the
default LLM extraction path: `MemoryModelInline` clears the override
for that option, both `/api/memory/extract` calls in `ProjectView`
only sent the messages, and the daemon never persists BYOK creds, so
`extractWithLLM(..., { chatAgentId: null })` always reached
`pickProvider()` with no chat context and fell through to env /
media-config — the wrong vendor for a BYOK chat that works for
inference.

Thread the live BYOK chat config through the extract endpoint as a
per-call snapshot:

- contracts: extend `ExtractMemoryRequest` with an optional
  `chatProvider` (provider/apiKey/baseUrl/apiVersion/model) and add
  `'chat-byok'` to the credentialSource enum.
- daemon: parse + validate `chatProvider` on `/api/memory/extract`
  (provider must be one of the five known shapes) and forward to
  `extractWithLLM` as a new option. `pickProvider()` gets a new
  path 2 that uses the snapshot directly with the per-protocol
  fast-model default — so a memory pass on `gpt-4o` / `claude-sonnet-4-5`
  silently turns into a cheap `gpt-4o-mini` / `claude-haiku-4-5` call
  instead of paying chat-tier rates for sediment work. Override and
  CLI-agent-constrained paths still win when they apply.
- web: `ProjectView` snapshots `apiProtocol` / `apiKey` / `baseUrl` /
  `apiVersion` from the live `AppConfig` on each BYOK extract call
  (both pre-turn heuristic-only and post-turn LLM phases). The
  picker's existing drift-resync effect already covers explicit
  overrides; this snapshot covers the implicit "Same as chat"
  default that the override flow can't reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): treat empty apiKey on PATCH as a real clear

MemoryModelInline silently re-PATCHes /api/memory/config whenever the
surrounding BYOK chat creds drift. The previous reuse branch lumped
`apiKey === ''` together with `apiKey === undefined`, so clearing the
chat API key from the picker quietly preserved the old daemon-side
secret and kept calling the provider on a stale credential.

Distinguish four states for the apiKey field:
- absent       -> preserve stored secret (form re-save without re-typing)
- ''           -> clear stored secret (user removed it from the picker)
- 'sk-...'     -> replace
- new provider -> ignore stored secret entirely

Add tests/memory-config-route.test.ts covering all four cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tom Huang 2026-05-11 15:45:42 +08:00 committed by GitHub
parent e11e86d468
commit e254d1280b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 6674 additions and 162 deletions

View file

@ -3,10 +3,10 @@
// and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real
// YAML (nested objects, flow-style, anchors), swap for `yaml` or `js-yaml`.
type FrontmatterScalar = string | number | boolean | null;
type FrontmatterValue = FrontmatterScalar | FrontmatterArray | FrontmatterObject;
interface FrontmatterArray extends Array<FrontmatterValue> {}
interface FrontmatterObject extends Record<string, FrontmatterValue> {}
export type FrontmatterScalar = string | number | boolean | null;
export type FrontmatterValue = FrontmatterScalar | FrontmatterArray | FrontmatterObject;
export interface FrontmatterArray extends Array<FrontmatterValue> {}
export interface FrontmatterObject extends Record<string, FrontmatterValue> {}
type FrontmatterContainer = FrontmatterObject | FrontmatterArray;
type StackEntry = {
indent: number;

View file

@ -0,0 +1,241 @@
// @ts-nocheck
// In-memory ring buffer of recent memory-extraction attempts.
//
// Both extractors write here:
// - 'llm' — the small-model extractor in `memory-llm.ts`
// - 'heuristic' — the regex pack in `memory.ts::extractFromMessage`
//
// Both used to be entirely silent. `pickProvider()` would return null
// when no API key was configured and the LLM call would just `return []`
// with no log, no SSE, no record. The heuristic regex pack had the same
// problem: when no pattern matched the user's turn (very common for any
// non-marker message), the function returned `[]` and the user had no
// signal that the regex even looked at the message. This module lifts
// both pipelines into a typed, observable stream of records keyed by
// `kind`.
//
// Each attempt creates one record. We keep the last N (cap is small —
// this is a UX surface, not an audit log) so the settings panel can
// render "recent extractions" with phases, providers, durations, and
// links into the entries that landed on disk.
//
// We piggyback on the existing `memoryEvents` emitter under a separate
// event name (`extraction`) so the regular `change` listeners don't
// trigger an entries re-fetch on every phase update.
import { randomUUID } from 'node:crypto';
import { memoryEvents } from './memory.js';
const MAX_RECORDS = 20;
const PREVIEW_CAP = 120;
const ERROR_CAP = 240;
const records = []; // newest first
function trimPreview(s) {
const text = String(s ?? '').replace(/\s+/g, ' ').trim();
if (text.length <= PREVIEW_CAP) return text;
return `${text.slice(0, PREVIEW_CAP - 1).trim()}`;
}
function trimError(s) {
const text = String(s ?? '').replace(/\r?\n/g, ' ').trim();
if (text.length <= ERROR_CAP) return text;
return `${text.slice(0, ERROR_CAP - 1).trim()}`;
}
function emit(record) {
// Defer the emit so the caller can append a synchronous follow-up
// update without firing two events back-to-back in the same tick.
// Cheaper than debouncing and good enough — the SSE path on the
// server flushes on the next event-loop turn anyway.
setImmediate(() => {
try {
memoryEvents.emit('extraction', { ...record });
} catch {
// SSE failures are not the extractor's problem.
}
});
}
function clone(record) {
return JSON.parse(JSON.stringify(record));
}
// Push a fresh record to the front and evict overflow off the back.
function pushNewest(record) {
records.unshift(record);
if (records.length > MAX_RECORDS) records.length = MAX_RECORDS;
}
// Public — start a new attempt. Returns the id; subsequent phase
// updates flow through `markRunning`, `markSuccess`, `markFailed`,
// `markSkipped`. We return only the id (not the record itself) so the
// caller can't accidentally mutate buffer state in place. `kind`
// defaults to 'llm' for backwards compat with the original single-
// writer call sites in memory-llm.ts.
export function startExtraction({ userMessage, kind = 'llm' }) {
const record = {
id: randomUUID(),
kind,
startedAt: Date.now(),
phase: 'running',
userMessagePreview: trimPreview(userMessage),
};
pushNewest(record);
emit(record);
return record.id;
}
function findById(id) {
return records.find((r) => r.id === id) ?? null;
}
export function markProvider(id, provider) {
const rec = findById(id);
if (!rec) return;
rec.provider = {
kind: provider.kind,
model: provider.model,
credentialSource: provider.credentialSource,
};
emit(rec);
}
export function markSkipped(id, reason) {
const rec = findById(id);
if (!rec) return;
rec.phase = 'skipped';
rec.reason = reason;
rec.finishedAt = Date.now();
emit(rec);
}
// One-shot variant — use when we want to record a skip that never went
// through the running phase (e.g. memory disabled, empty user message,
// no provider configured). Returns the record's id so the caller can
// pass it to listExtractions consumers if needed.
export function recordSkip({ userMessage, reason, kind = 'llm' }) {
const record = {
id: randomUUID(),
kind,
startedAt: Date.now(),
finishedAt: Date.now(),
phase: 'skipped',
reason,
userMessagePreview: trimPreview(userMessage),
};
pushNewest(record);
emit(record);
return record.id;
}
// One-shot variant for the heuristic regex pack — synchronous, no
// streaming phases, completes in microseconds. Use this instead of
// startExtraction()/markSuccess() so the regex extractor doesn't bounce
// two SSE frames per turn (a 'running' immediately followed by
// 'success'). When `writtenCount` is 0 we record the attempt as
// 'skipped' with reason 'no-match' so the UI can colour it like the
// other skip rows ("regex looked, found nothing") instead of pretending
// the regex never ran.
export function recordHeuristic({ userMessage, writtenCount, writtenIds }) {
const written = Number.isFinite(writtenCount)
? Math.max(0, Math.floor(writtenCount))
: 0;
const ids = Array.isArray(writtenIds) ? writtenIds.slice(0, 12) : [];
const now = Date.now();
const record = {
id: randomUUID(),
kind: 'heuristic',
startedAt: now,
finishedAt: now,
phase: written > 0 ? 'success' : 'skipped',
userMessagePreview: trimPreview(userMessage),
writtenCount: written,
writtenIds: ids,
...(written === 0 ? { reason: 'no-match' } : {}),
};
pushNewest(record);
emit(record);
return record.id;
}
export function markProposed(id, proposedCount) {
const rec = findById(id);
if (!rec) return;
rec.proposedCount = proposedCount;
emit(rec);
}
export function markSuccess(id, { writtenCount, writtenIds }) {
const rec = findById(id);
if (!rec) return;
rec.phase = 'success';
rec.writtenCount = writtenCount;
rec.writtenIds = Array.isArray(writtenIds) ? writtenIds.slice(0, 12) : [];
rec.finishedAt = Date.now();
emit(rec);
}
export function markFailed(id, error) {
const rec = findById(id);
if (!rec) return;
rec.phase = 'failed';
rec.error = trimError(error?.message ?? error ?? 'unknown error');
rec.finishedAt = Date.now();
emit(rec);
}
// Public — newest-first snapshot. Cloned so callers can't mutate the
// buffer through the returned reference.
export function listExtractions() {
return records.map(clone);
}
// Public — drop one record by id. Returns the count actually removed
// (0 when the id was already gone — caller still gets a 200 from the
// HTTP endpoint so a dangling double-click isn't surfaced as an error).
// Emits a synthetic `extraction` event with `phase: 'deleted'` so any
// open settings panel can drop the row immediately without a refetch.
export function removeExtraction(id) {
const idx = records.findIndex((r) => r.id === id);
if (idx < 0) return 0;
const [removed] = records.splice(idx, 1);
setImmediate(() => {
try {
memoryEvents.emit('extraction', { ...removed, phase: 'deleted' });
} catch {
// SSE failures are not the extractor's problem.
}
});
return 1;
}
// Public — wipe the whole buffer. Returns the count removed. Emits a
// single `extractions-cleared` event so the UI can drop everything in
// one render rather than firing N row-level deletes.
export function clearExtractions() {
const removed = records.length;
records.length = 0;
if (removed > 0) {
setImmediate(() => {
try {
memoryEvents.emit('extraction', {
id: 'all',
phase: 'cleared',
startedAt: Date.now(),
finishedAt: Date.now(),
});
} catch {
// SSE failures are not the extractor's problem.
}
});
}
return removed;
}
// Test-only — wipe the buffer. Not exported for production paths but
// the tests need a deterministic starting state.
export function __resetExtractionsForTests() {
records.length = 0;
}

View file

@ -0,0 +1,875 @@
// @ts-nocheck
// LLM-driven memory extractor.
//
// The heuristic regex pack in `memory.ts` only catches explicit markers
// ("remember:", "记住", "我喜欢"…). For everything else — implicit
// preferences, role, ongoing-work context — we ask a small fast model
// to look at the just-finished turn and the existing memory and return
// a JSON list of facts to add.
//
// This module is fire-and-forget: the chat run finishes and triggers
// extraction in the background. Output lands in the same MD store so
// the next turn's prompt picks it up automatically.
//
// Provider selection (in order):
// 0. memory `.config.json` extraction override → user-supplied
// provider/model/baseUrl/apiKey/apiVersion from the Memory model
// picker. The override may pick any of four providers — anthropic,
// openai, azure (openai-compatible at a per-resource URL), or
// google gemini. This is the only path that lets a Local-CLI user
// (no env-var key in the daemon's environment) point memory
// extraction at, say, their personal Anthropic key with a
// specific Haiku build instead of falling all the way through to
// gpt-4o-mini. When the override carries the provider but no
// apiKey we fall back to the corresponding env var (or the media-
// 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
// (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
// so the UI can surface "configure a key to enable LLM memory"
// instead of staying silent
//
// Every attempt — whether it actually called the model or short-circuited
// — produces a record in `memory-extractions.ts` so the settings panel
// can show running / skipped / success / failed states in real time.
import {
composeMemoryBody,
listMemoryEntries,
readMemoryConfig,
upsertMemoryEntry,
memoryEvents,
} from './memory.js';
import {
startExtraction,
recordSkip,
markProvider,
markSkipped,
markProposed,
markSuccess,
markFailed,
} from './memory-extractions.js';
import { resolveProviderConfig } from './media-config.js';
const SYSTEM_PROMPT = `You are a memory extractor for a personal AI design assistant.
Given the user's most recent message (and optionally the assistant's reply), plus a snapshot of the existing memory store, decide whether ANYTHING in this turn is worth remembering across future conversations.
A fact is worth remembering when ALL of these are true:
- It's about the user, their preferences, their tools, their ongoing work, OR a stable reference (a Linear board id, a Slack channel, a teammate name).
- It will plausibly still be true in a week.
- It would change how an assistant responds in a later, unrelated chat.
A fact is NOT worth remembering when ANY of these is true:
- It's a transient state (current task, what file they're editing right now).
- It's already captured in the existing memory.
- It's just the user asking a question or describing a one-off bug.
- It's something the assistant said about itself.
- It's a code snippet, an output, or a paste.
Output STRICT JSON in this exact shape nothing else, no prose, no markdown fences:
{
"entries": [
{ "type": "user|feedback|project|reference", "name": "short title (≤ 60 chars)", "description": "one-line summary (≤ 140 chars)", "body": "the actual remembered fact, 1-3 sentences" }
]
}
If there's nothing worth remembering, return: {"entries": []}
Type rules:
- user: who they are, role, expertise, long-term goals
- feedback: corrections / preferences about how to work ("don't add comments unless asked")
- project: ongoing initiatives, deadlines, why-decisions; usually time-bounded
- reference: pointers to external systems (Linear projects, Slack channels, dashboards)`;
// Provider defaults are centralised so the override path and the
// auto-pick path can't drift apart. When the user picks "Custom →
// anthropic" without typing a model, we still want the same
// claude-haiku-4-5 fallback the env path uses.
//
// Azure has no useful baseUrl default — every Azure resource has its
// own `https://<resource>.openai.azure.com` host, so the user must
// supply theirs. We still emit an empty default here so a missing
// override doesn't crash with `undefined` when accessed.
const PROVIDER_DEFAULTS = {
anthropic: {
model: 'claude-haiku-4-5',
baseUrl: 'https://api.anthropic.com',
},
openai: {
model: 'gpt-4o-mini',
baseUrl: 'https://api.openai.com',
},
azure: {
model: 'gpt-4o-mini',
baseUrl: '',
apiVersion: '2024-10-21',
},
google: {
model: 'gemini-2.0-flash',
baseUrl: 'https://generativelanguage.googleapis.com',
},
// Ollama Cloud speaks OpenAI-compatible chat-completions, so the
// extractor just routes through callOpenAI with the ollama base URL
// and the user's Ollama Cloud API key. The default model is a small
// open-weight model so the auto-pick produces a deterministic answer
// for users who haven't customised the picker; users who care can
// pick anything off the picker's `Custom...` list.
ollama: {
model: 'gemma3:4b',
baseUrl: 'https://ollama.com',
},
};
// Map an explicit override provider to the env var the daemon should
// consult when the override doesn't carry its own apiKey. The fallback
// chain stays the same as before for anthropic/openai; azure uses the
// AZURE_OPENAI_API_KEY convention; google uses GOOGLE_API_KEY (matching
// the gemini SDK's expectation, with GEMINI_API_KEY as a secondary).
function envKeyFor(provider) {
if (provider === 'anthropic') return process.env.ANTHROPIC_API_KEY?.trim() || '';
if (provider === 'openai') return process.env.OPENAI_API_KEY?.trim() || '';
if (provider === 'azure') {
return (
process.env.AZURE_OPENAI_API_KEY?.trim()
|| process.env.AZURE_API_KEY?.trim()
|| ''
);
}
if (provider === 'google') {
return (
process.env.GOOGLE_API_KEY?.trim()
|| process.env.GEMINI_API_KEY?.trim()
|| ''
);
}
if (provider === 'ollama') {
return process.env.OLLAMA_API_KEY?.trim() || '';
}
return '';
}
// Map a chat agent id to the API protocol family it speaks under the
// hood. This is the bridge that makes "follow chat" actually mean
// something for memory extraction in CLI mode: when the user is on
// Claude Code (claude → anthropic) we don't want memory to silently
// fall through to whatever OpenAI key happens to be in media-config —
// that produces the very confusing "openai/gpt-4o-mini" attempts the
// user sees while they think they're "using Claude". Anything we don't
// recognise stays unconstrained (returns null) so the legacy
// cross-provider fallback can still kick in for setups we don't model.
function chatProtocolFromAgentId(agentId) {
if (!agentId || typeof agentId !== 'string') return null;
const id = agentId.trim().toLowerCase();
if (id === 'claude') return 'anthropic';
if (id === 'gemini') return 'google';
// Codex, OpenCode, Qwen, DeepSeek, Kimi, Copilot, Pi, Kiro, Kilo,
// Vibe, Devin, Hermes, Cursor-Agent, Qoder all use the OpenAI chat-
// completions wire format.
if (
id === 'codex'
|| id === 'opencode'
|| id === 'qwen'
|| id === 'deepseek'
|| id === 'kimi'
|| id === 'copilot'
|| id === 'pi'
|| id === 'kiro'
|| id === 'kilo'
|| id === 'vibe'
|| id === 'devin'
|| id === 'hermes'
|| id === 'cursor-agent'
|| id === 'qoder'
) {
return 'openai';
}
return null;
}
// 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
// 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
// legacy "claude user, openai gpt-4o-mini extracts in the
// 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
// 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
// default extractor follows the chat configuration instead of
// falling through to env / media-config which the daemon never
// saw the user configure. The model deliberately overrides the
// 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
// 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
//
// 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
// 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
// unconstrained chain, which is what the daemon used to do and what
// pre-context callers (the HTTP /api/memory/extract endpoint) still
// expect. `chatProvider` is the BYOK chat-config snapshot threaded
// 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) {
let override = null;
if (dataDir) {
try {
const cfg = await readMemoryConfig(dataDir);
if (cfg?.extraction?.provider) override = cfg.extraction;
} catch (err) {
console.warn(
'[memory-llm] failed to read memory config override',
err?.message ?? err,
);
}
}
if (override) {
const defaults = PROVIDER_DEFAULTS[override.provider];
const explicitKey =
typeof override.apiKey === 'string' && override.apiKey.trim()
? override.apiKey.trim()
: '';
const envKey = envKeyFor(override.provider);
let resolvedKey = explicitKey || envKey;
let credentialSource = explicitKey
? 'memory-config'
: (envKey ? 'env' : null);
// Last-chance: an openai-shaped override (openai or azure) with no
// explicit/env key can still borrow the media-config OpenAI key the
// user already typed. Anthropic / google have no media counterpart
// today.
if (
!resolvedKey
&& (override.provider === 'openai' || override.provider === 'azure')
&& projectRoot
) {
try {
const cred = await resolveProviderConfig(projectRoot, 'openai');
if (cred?.apiKey?.trim()) {
resolvedKey = cred.apiKey.trim();
credentialSource = 'media-config';
}
} catch {
// Ignore — we'll record a no-provider skip below.
}
}
if (!resolvedKey) return null;
const baseUrl =
(typeof override.baseUrl === 'string' && override.baseUrl.trim())
|| defaults.baseUrl;
if (override.provider === 'azure' && !baseUrl) {
// Azure with no resource URL is unrecoverable — bail rather than
// logging a confusing 404 from `https:///openai/deployments/...`.
return null;
}
return {
kind: override.provider,
apiKey: resolvedKey,
model:
(typeof override.model === 'string' && override.model.trim())
|| defaults.model,
baseUrl,
apiVersion:
override.provider === 'azure'
? (typeof override.apiVersion === 'string' && override.apiVersion.trim())
|| PROVIDER_DEFAULTS.azure.apiVersion
: '',
credentialSource,
};
}
const envOverrideModel = (process.env.OD_MEMORY_MODEL || '').trim();
// Chat-protocol-constrained branch (path 1). Only run when we know
// which CLI is in use AND it maps to one of the four providers; we
// refuse to wander out of the chat protocol's family even when an
// 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 envKey = envKeyFor(chatProtocol);
if (envKey) {
const defaults = PROVIDER_DEFAULTS[chatProtocol];
return {
kind: chatProtocol,
apiKey: envKey,
model: envOverrideModel || defaults.model,
baseUrl:
(chatProtocol === 'anthropic' && process.env.ANTHROPIC_BASE_URL)
|| (chatProtocol === 'openai' && process.env.OPENAI_BASE_URL)
|| defaults.baseUrl,
apiVersion: chatProtocol === 'azure' ? defaults.apiVersion : '',
credentialSource: 'env',
};
}
// Secondary fallback for openai-compatible CLIs: the user already
// typed an OpenAI key under Settings → Media providers, so we can
// borrow it for memory extraction without making them paste it
// twice. We do NOT try this for anthropic/google chats because the
// media-config table only has openai-shaped credentials today.
if (chatProtocol === 'openai' && projectRoot) {
try {
const cred = await resolveProviderConfig(projectRoot, 'openai');
if (cred && typeof cred.apiKey === 'string' && cred.apiKey.trim()) {
return {
kind: 'openai',
apiKey: cred.apiKey.trim(),
model:
envOverrideModel || cred.model || PROVIDER_DEFAULTS.openai.model,
baseUrl: (cred.baseUrl && String(cred.baseUrl).trim())
|| PROVIDER_DEFAULTS.openai.baseUrl,
apiVersion: '',
credentialSource: 'media-config',
};
}
} catch (err) {
console.warn(
'[memory-llm] media-config lookup failed (chat-constrained)',
err?.message ?? err,
);
}
}
// The chat protocol is known but no key for it is available. Bail
// out instead of wandering — recording 'skipped: no-provider' is
// strictly more useful than silently running on a foreign vendor.
return null;
}
// BYOK chat-config snapshot (path 2). The web app forwards the live
// chat provider/key/baseUrl/apiVersion on every API-mode extraction
// call so the daemon can run extraction against the same vendor the
// user is chatting with — even though the daemon never persists
// BYOK creds itself. Use the per-protocol fast-model default instead
// of the chat model the user is paying for, so a memory pass on a
// big chat model (gpt-4o, claude-sonnet-4-5) silently turns into a
// cheap haiku/mini call. The caller can opt into using the chat
// model verbatim by setting `chatProvider.model`.
if (
chatProvider
&& chatProvider.provider
&& PROVIDER_DEFAULTS[chatProvider.provider]
) {
const apiKey =
typeof chatProvider.apiKey === 'string' ? chatProvider.apiKey.trim() : '';
if (apiKey) {
const defaults = PROVIDER_DEFAULTS[chatProvider.provider];
const baseUrl =
(typeof chatProvider.baseUrl === 'string' && chatProvider.baseUrl.trim())
|| defaults.baseUrl;
// Azure with no resource URL is unrecoverable — same guard as
// the override path above.
if (chatProvider.provider !== 'azure' || baseUrl) {
const explicitModel =
typeof chatProvider.model === 'string' && chatProvider.model.trim()
? chatProvider.model.trim()
: '';
return {
kind: chatProvider.provider,
apiKey,
model: envOverrideModel || explicitModel || defaults.model,
baseUrl,
apiVersion:
chatProvider.provider === 'azure'
? (typeof chatProvider.apiVersion === 'string'
&& chatProvider.apiVersion.trim())
|| PROVIDER_DEFAULTS.azure.apiVersion
: '',
credentialSource: 'chat-byok',
};
}
}
}
if (process.env.ANTHROPIC_API_KEY) {
return {
kind: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY,
model: envOverrideModel || PROVIDER_DEFAULTS.anthropic.model,
baseUrl:
process.env.ANTHROPIC_BASE_URL || PROVIDER_DEFAULTS.anthropic.baseUrl,
credentialSource: 'env',
};
}
if (process.env.OPENAI_API_KEY) {
return {
kind: 'openai',
apiKey: process.env.OPENAI_API_KEY,
model: envOverrideModel || PROVIDER_DEFAULTS.openai.model,
baseUrl: process.env.OPENAI_BASE_URL || PROVIDER_DEFAULTS.openai.baseUrl,
credentialSource: 'env',
};
}
// Fallback: reuse the OpenAI key the user already configured for media
// generation. Most Local-CLI Claude users don't have an
// ANTHROPIC_API_KEY in the daemon's environment (Claude Code logs in
// via OAuth) but they often have an OpenAI key in Settings → Media
// providers. Without this fallback the LLM extraction stage stays dark
// for them and only the regex-based heuristic ever runs.
if (projectRoot) {
try {
const cred = await resolveProviderConfig(projectRoot, 'openai');
if (cred && typeof cred.apiKey === 'string' && cred.apiKey.trim()) {
return {
kind: 'openai',
apiKey: cred.apiKey.trim(),
model:
envOverrideModel || cred.model || PROVIDER_DEFAULTS.openai.model,
baseUrl: (cred.baseUrl && String(cred.baseUrl).trim())
|| PROVIDER_DEFAULTS.openai.baseUrl,
credentialSource: 'media-config',
};
}
} catch (err) {
console.warn(
'[memory-llm] failed to read media-config for fallback',
err?.message ?? err,
);
}
}
return null;
}
function renderUserPayload({ userMessage, assistantMessage, currentMemory }) {
const parts = [];
parts.push('## Existing memory');
parts.push(currentMemory && currentMemory.trim().length > 0
? currentMemory
: '(empty)');
parts.push('');
parts.push('## User message');
parts.push(String(userMessage || '').slice(0, 4000));
if (assistantMessage && assistantMessage.trim().length > 0) {
parts.push('');
parts.push('## Assistant reply');
parts.push(String(assistantMessage).slice(0, 4000));
}
parts.push('');
parts.push(
'Return ONLY the JSON object described in the system prompt — no prose, no fences.',
);
return parts.join('\n');
}
// 30s ceiling. The chat run has long since finished and the user is
// staring at the settings panel waiting for a green/red pill — leaving
// a half-dead fetch in flight for two minutes (the default undici
// connect timeout) makes the failure feel even worse than it is.
const FETCH_TIMEOUT_MS = 30_000;
// Append `/v1<suffix>` to a base URL only when the URL doesn't already
// carry an explicit `/vN` segment. Mirrors the same conditional path
// build the chat proxy and connection-test routes use, so a custom
// OpenAI-compatible endpoint whose saved baseUrl already contains
// `/v1` (local servers, proxies that re-host OpenAI under a fixed
// prefix) does not become `/v1/v1/chat/completions` and silently fail
// every memory extraction even though chat through the same provider
// works. Anthropic's `/v1/messages` and OpenAI's `/v1/chat/completions`
// both flow through this; Azure and Gemini build their URLs
// differently and don't need it.
function appendVersionedApiPath(baseUrl, suffix) {
const url = new URL(baseUrl);
const pathname = url.pathname.replace(/\/+$/, '');
url.pathname = /\/v\d+(\/|$)/.test(pathname)
? `${pathname}${suffix}`
: `${pathname}/v1${suffix}`;
return url.toString();
}
// Build a standard AbortSignal that fires after FETCH_TIMEOUT_MS so a
// stalled provider call surfaces as a 'failed' record instead of
// hanging the attempt indefinitely.
function withTimeout(ms) {
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error(`timeout ${ms}ms`)), ms);
return controller.signal;
}
// undici raises a generic `TypeError: fetch failed` on every network
// error and tucks the real cause under `err.cause` (a Node `Error` or
// `AggregateError` with `.code` / `.errors`). The settings UI just
// shows `error.message`, so without unwrapping the cause the user
// sees "fetch failed" with no clue whether DNS broke, the firewall
// reset the connection, or the request timed out. Surface the most
// useful piece — the OS error code if present, otherwise the cause's
// message — appended in parentheses. We deliberately don't include
// both: `cause.message` typically already embeds the code (e.g.
// "read ECONNRESET"), and showing "ECONNRESET · read ECONNRESET"
// would just double the noise.
function describeFetchError(err) {
const head = err?.message || String(err);
const cause = err?.cause;
if (!cause) return head;
const codeRaw = cause.code ? String(cause.code) : '';
const msgRaw =
cause.message && cause.message !== head ? String(cause.message) : '';
// Prefer the OS error code on its own when the cause's message just
// wraps it (the common case for ECONNRESET / ENOTFOUND / ETIMEDOUT).
// Fall back to the message when there's no code, or when the message
// adds detail beyond the code (e.g. "Hostname/IP does not match
// certificate's altnames").
let detail = '';
if (codeRaw && msgRaw) {
const m = msgRaw.toLowerCase();
detail = m.includes(codeRaw.toLowerCase()) ? codeRaw : `${codeRaw}: ${msgRaw}`;
} else {
detail = codeRaw || msgRaw;
}
// AggregateError: surface the first inner code that adds new info.
// Most of these are six identical DNS errors, so dedupe aggressively.
if (!detail && Array.isArray(cause.errors)) {
for (const inner of cause.errors) {
const innerCode = inner?.code ? String(inner.code) : '';
const innerMsg = inner?.message ? String(inner.message) : '';
const candidate = innerCode || innerMsg;
if (candidate) {
detail = candidate;
break;
}
}
}
return detail ? `${head} (${detail})` : head;
}
async function callAnthropic(provider, system, user) {
let resp;
try {
resp = await fetch(appendVersionedApiPath(provider.baseUrl, '/messages'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: provider.model,
max_tokens: 1024,
system,
messages: [{ role: 'user', content: user }],
}),
signal: withTimeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
throw new Error(describeFetchError(err));
}
if (!resp.ok) {
throw new Error(`anthropic ${resp.status}: ${await resp.text().catch(() => '')}`);
}
const json = await resp.json();
const block = (json?.content || []).find((b) => b?.type === 'text');
return block?.text ?? '';
}
async function callOpenAI(provider, system, user) {
let resp;
try {
resp = await fetch(
appendVersionedApiPath(provider.baseUrl, '/chat/completions'),
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${provider.apiKey}`,
},
body: JSON.stringify({
model: provider.model,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: withTimeout(FETCH_TIMEOUT_MS),
},
);
} catch (err) {
throw new Error(describeFetchError(err));
}
if (!resp.ok) {
throw new Error(`openai ${resp.status}: ${await resp.text().catch(() => '')}`);
}
const json = await resp.json();
return json?.choices?.[0]?.message?.content ?? '';
}
// Azure OpenAI speaks the same chat-completions JSON as OpenAI, but on
// a per-deployment URL and with `api-key:` instead of `Authorization:`.
// `provider.model` here is the Azure deployment name (the user typed it
// into the model field — that's what the chat picker calls "Deployment
// (Model)" too), not the underlying model family.
async function callAzure(provider, system, user) {
const base = String(provider.baseUrl || '').replace(/\/+$/, '');
const deployment = encodeURIComponent(provider.model);
const apiVersion = encodeURIComponent(
provider.apiVersion || PROVIDER_DEFAULTS.azure.apiVersion,
);
const url = `${base}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'api-key': provider.apiKey,
},
body: JSON.stringify({
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: withTimeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
throw new Error(describeFetchError(err));
}
if (!resp.ok) {
throw new Error(`azure ${resp.status}: ${await resp.text().catch(() => '')}`);
}
const json = await resp.json();
return json?.choices?.[0]?.message?.content ?? '';
}
// Google Gemini's REST surface uses a different request shape:
// system instructions go in `systemInstruction`, the conversation is
// `contents[]` with `role` + `parts`, and the API key is a query
// parameter rather than a header. `responseMimeType: application/json`
// gets us the strict JSON output the parser expects.
async function callGoogle(provider, system, user) {
const base = String(provider.baseUrl || '').replace(/\/+$/, '');
const model = encodeURIComponent(provider.model);
const url = `${base}/v1beta/models/${model}:generateContent?key=${encodeURIComponent(provider.apiKey)}`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
systemInstruction: { role: 'system', parts: [{ text: system }] },
contents: [{ role: 'user', parts: [{ text: user }] }],
generationConfig: { responseMimeType: 'application/json' },
}),
signal: withTimeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
throw new Error(describeFetchError(err));
}
if (!resp.ok) {
throw new Error(`google ${resp.status}: ${await resp.text().catch(() => '')}`);
}
const json = await resp.json();
const parts = json?.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('');
}
return '';
}
// Tolerant JSON parse — the model occasionally wraps output in ```json
// fences even when told not to. Strip those defensively.
function parseEntries(rawText) {
if (typeof rawText !== 'string') return [];
let text = rawText.trim();
if (text.startsWith('```')) {
text = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
}
let parsed;
try {
parsed = JSON.parse(text);
} catch {
// Last-ditch: pull the first {...} block.
const match = /\{[\s\S]*\}/.exec(text);
if (!match) return [];
try {
parsed = JSON.parse(match[0]);
} catch {
return [];
}
}
const list = Array.isArray(parsed?.entries) ? parsed.entries : [];
const validTypes = new Set(['user', 'feedback', 'project', 'reference']);
return list
.filter(
(e) =>
e &&
typeof e === 'object' &&
validTypes.has(e.type) &&
typeof e.name === 'string' &&
e.name.trim().length > 0 &&
typeof e.body === 'string' &&
e.body.trim().length > 0,
)
.slice(0, 6); // hard cap so a confused model can't flood the store
}
function alreadyKnown(existing, candidate) {
const candKey = `${candidate.type}::${candidate.name.toLowerCase().trim()}`;
for (const e of existing) {
if (`${e.type}::${e.name.toLowerCase().trim()}` === candKey) return true;
}
return false;
}
export async function extractWithLLM(dataDir, input, options) {
const projectRoot = options?.projectRoot ?? null;
const chatAgentId = options?.chatAgentId ?? null;
// 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
// pickProvider() can run "Same as chat" extraction against the
// user's actual chat provider.
const chatProvider = options?.chatProvider ?? null;
const userMessage = String(input?.userMessage || '').trim();
const cfg = await readMemoryConfig(dataDir);
if (!cfg.enabled) {
recordSkip({ userMessage, reason: 'memory-disabled' });
return [];
}
if (userMessage.length === 0) {
recordSkip({ userMessage, reason: 'empty-message' });
return [];
}
const provider = await pickProvider(
projectRoot,
dataDir,
chatAgentId,
chatProvider,
);
if (!provider) {
recordSkip({ userMessage, reason: 'no-provider' });
return [];
}
// 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 });
markProvider(attemptId, {
kind: provider.kind,
model: provider.model,
credentialSource: provider.credentialSource,
});
let currentMemory = '';
let existingEntries = [];
try {
[currentMemory, existingEntries] = await Promise.all([
composeMemoryBody(dataDir),
listMemoryEntries(dataDir),
]);
} catch {
// Fresh store — proceed with empty context.
}
const userPayload = renderUserPayload({
userMessage,
assistantMessage: input?.assistantMessage,
currentMemory,
});
let raw = '';
try {
if (provider.kind === 'anthropic') {
raw = await callAnthropic(provider, SYSTEM_PROMPT, userPayload);
} else if (provider.kind === 'azure') {
raw = await callAzure(provider, SYSTEM_PROMPT, userPayload);
} else if (provider.kind === 'google') {
raw = await callGoogle(provider, SYSTEM_PROMPT, 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);
}
} catch (err) {
// err.message is already pre-formatted by describeFetchError() when
// the call layer caught a network error. For HTTP-level failures
// (`anthropic 401: …`) the message is already user-facing too.
console.warn(`[memory-llm] ${provider.kind} call failed`, err?.message ?? err);
markFailed(attemptId, err);
return [];
}
let proposed;
try {
proposed = parseEntries(raw);
} catch (err) {
markFailed(attemptId, err);
return [];
}
markProposed(attemptId, proposed.length);
if (proposed.length === 0) {
markSuccess(attemptId, { writtenCount: 0, writtenIds: [] });
return [];
}
const written = [];
for (const cand of proposed) {
if (alreadyKnown(existingEntries, cand)) continue;
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(),
},
// Suppress per-entry events; we batch a single 'extract' below
// so the toast says "Memory updated (3 · LLM)" once.
{ silent: true, source: 'llm' },
);
written.push({
id: entry.id,
name: entry.name,
description: entry.description,
type: entry.type,
updatedAt: entry.updatedAt,
});
} catch (err) {
console.warn('[memory-llm] write failed', err?.message ?? err);
}
}
if (written.length > 0) {
memoryEvents.emit('change', {
kind: 'extract',
count: written.length,
source: 'llm',
at: Date.now(),
});
}
markSuccess(attemptId, {
writtenCount: written.length,
writtenIds: written.map((e) => e.id),
});
return written;
}

741
apps/daemon/src/memory.ts Normal file
View file

@ -0,0 +1,741 @@
// @ts-nocheck
// Filesystem-backed markdown memory store.
//
// 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 }
//
// Frontmatter format (matches Claude Code's auto-memory pattern):
// ---
// name: User role
// description: User is a senior FE engineer working on Open Design.
// type: user
// ---
//
// {markdown body}
//
// The store is intentionally dependency-free. We piggyback on the
// existing daemon `frontmatter.ts` parser. Concurrency: writes are
// last-writer-wins on a per-file basis; the daemon only ever has one
// chat run at a time touching memory so we don't need locking yet.
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { parseFrontmatter } from './frontmatter.js';
// Imported lazily through the memory-extractions module by the call
// sites below so a future test-only build of memory.ts that stubs the
// store can still tree-shake the ring buffer. We use a static import
// here because memory.ts is the chat hot path — a dynamic import per
// turn would add a microtask hop for no real benefit.
import { recordHeuristic, recordSkip } from './memory-extractions.js';
// Tiny in-process bus. The HTTP layer (`/api/memory/events`) subscribes
// to this and forwards events to any open SSE client; the storage
// helpers below emit on every write so the web UI auto-refreshes
// whenever memory changes — whether the change came from the chat
// hook, the LLM extractor, the settings panel, or `curl`.
export const memoryEvents = new EventEmitter();
memoryEvents.setMaxListeners(64);
export type MemoryChangeKind =
| 'upsert'
| 'delete'
| 'index'
| 'config'
| 'extract';
export interface MemoryChangeEvent {
kind: MemoryChangeKind;
// Optional details — populated for upsert / delete; absent for index /
// config so the frontend just re-fetches the list.
id?: string;
name?: string;
description?: string;
type?: string;
// For 'extract' events, the size of the batch that was added in one
// pass. Lets the toast say "Memory updated (3 new)" instead of three
// separate toasts.
count?: number;
source?: 'heuristic' | 'llm' | 'manual';
enabled?: boolean;
at: number;
}
function emitChange(event: Omit<MemoryChangeEvent, 'at'>): void {
memoryEvents.emit('change', { ...event, at: Date.now() });
}
const INDEX_FILE = 'MEMORY.md';
const CONFIG_FILE = '.config.json';
const VALID_TYPES = new Set(['user', 'feedback', 'project', 'reference']);
const DEFAULT_INDEX = `# Memory
This is your auto-memory index. Each line points to a per-fact \`.md\`
file in the same folder. Lines you delete here stop being injected into
new chats; the underlying fact file stays on disk so you can paste it
back if you change your mind.
`;
export function memoryDir(dataDir) {
return path.join(dataDir, 'memory');
}
async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true });
}
function isValidType(t) {
return typeof t === 'string' && VALID_TYPES.has(t);
}
// Slug rules: lowercase, alphanumeric + underscore. Strip everything
// else. Always prefixed by `<type>_` so a file's category is visible
// in `ls` without parsing frontmatter. When the source name is purely
// non-ASCII (CJK / emoji) the cleaned slug ends up empty; in that case
// we hash the raw name so two distinct Chinese memories don't collide
// on the `<type>_note` fallback.
export function deriveMemoryId(type, name) {
const safeType = isValidType(type) ? type : 'user';
const raw = String(name || '');
const cleaned = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48);
if (cleaned.length > 0) return `${safeType}_${cleaned}`;
// FNV-1a 32-bit on the original name. Tiny, deterministic, no
// dependencies. Collisions are still possible, but for the dozens of
// memories a user is likely to accumulate, the birthday risk is
// negligible.
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < raw.length; i++) {
h = (h ^ raw.charCodeAt(i)) >>> 0;
h = Math.imul(h, 0x01000193) >>> 0;
}
return `${safeType}_n${h.toString(36)}`;
}
function entryPath(dataDir, id) {
// Defence in depth: the id arrives from the network. Reject anything
// that could escape the memory dir or break the .md convention.
if (typeof id !== 'string' || !/^[a-z0-9_]+$/.test(id) || id.length > 96) {
throw new Error('invalid memory id');
}
return path.join(memoryDir(dataDir), `${id}.md`);
}
function indexPath(dataDir) {
return path.join(memoryDir(dataDir), INDEX_FILE);
}
function configPath(dataDir) {
return path.join(memoryDir(dataDir), CONFIG_FILE);
}
// Whitelist of fields the extraction override may contain. Anything else
// in the patch is dropped to keep `.config.json` from accumulating
// arbitrary user-supplied keys (e.g. a typo'd field that quietly breaks
// the extractor on the next restart).
const VALID_EXTRACTION_PROVIDERS = new Set([
'anthropic',
'openai',
'azure',
'google',
'ollama',
]);
function normalizeExtractionPatch(input) {
if (!input || typeof input !== 'object') return null;
const provider = input.provider;
if (!VALID_EXTRACTION_PROVIDERS.has(provider)) return null;
const out = { provider };
if (typeof input.model === 'string' && input.model.trim()) {
out.model = input.model.trim();
}
if (typeof input.baseUrl === 'string' && input.baseUrl.trim()) {
out.baseUrl = input.baseUrl.trim();
}
if (typeof input.apiKey === 'string' && input.apiKey.trim()) {
out.apiKey = input.apiKey.trim();
}
if (typeof input.apiVersion === 'string' && input.apiVersion.trim()) {
out.apiVersion = input.apiVersion.trim();
}
return out;
}
export async function readMemoryConfig(dataDir) {
try {
const raw = await fsp.readFile(configPath(dataDir), 'utf8');
const parsed = JSON.parse(raw);
return {
enabled: parsed?.enabled !== 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 };
}
}
// Patch shape:
// { enabled?: 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.
export async function writeMemoryConfig(dataDir, patch) {
const current = await readMemoryConfig(dataDir);
const next = {
enabled:
typeof patch?.enabled === 'boolean' ? patch.enabled : current.enabled,
extraction: current.extraction,
};
if (Object.prototype.hasOwnProperty.call(patch || {}, 'extraction')) {
next.extraction = patch.extraction === null
? null
: normalizeExtractionPatch(patch.extraction);
}
if (typeof next.enabled !== 'boolean') next.enabled = true;
await ensureDir(memoryDir(dataDir));
await fsp.writeFile(configPath(dataDir), JSON.stringify(next, null, 2));
if (current.enabled !== next.enabled) {
emitChange({ kind: 'config', enabled: next.enabled });
}
// We don't emit a separate change event for extraction overrides — the
// chat hot path doesn't need to react, and the settings panel re-reads
// the config on every PATCH response anyway. Adding an extra event
// would just trigger redundant entry-list re-fetches in MemorySection.
return next;
}
// Public — returns the masked shape consumed by GET /api/memory.
// Keeps the secret out of the DOM but lets the UI render "configured" /
// "•••• abcd" affordances without round-tripping through writeConfig.
export function maskMemoryExtractionConfig(extraction) {
if (!extraction) return null;
const apiKey = typeof extraction.apiKey === 'string' ? extraction.apiKey : '';
return {
provider: extraction.provider,
model: typeof extraction.model === 'string' ? extraction.model : '',
baseUrl: typeof extraction.baseUrl === 'string' ? extraction.baseUrl : '',
apiVersion:
typeof extraction.apiVersion === 'string' ? extraction.apiVersion : '',
apiKeyTail: apiKey ? apiKey.slice(-4) : '',
apiKeyConfigured: Boolean(apiKey),
};
}
export async function readMemoryIndex(dataDir) {
try {
return await fsp.readFile(indexPath(dataDir), 'utf8');
} catch {
return DEFAULT_INDEX;
}
}
export async function writeMemoryIndex(dataDir, body, options) {
await ensureDir(memoryDir(dataDir));
await fsp.writeFile(indexPath(dataDir), String(body ?? ''));
if (!options?.silent) emitChange({ kind: 'index' });
}
function summarize(id, raw, mtime) {
const { data, body } = parseFrontmatter(raw);
const type = isValidType(data?.type) ? data.type : 'user';
return {
summary: {
id,
name: typeof data?.name === 'string' && data.name ? data.name : id,
description: typeof data?.description === 'string' ? data.description : '',
type,
updatedAt: mtime,
},
body: typeof body === 'string' ? body.trimStart() : '',
};
}
export async function listMemoryEntries(dataDir) {
const dir = memoryDir(dataDir);
let names = [];
try {
names = await fsp.readdir(dir);
} catch {
return [];
}
const out = [];
for (const name of names) {
if (!name.endsWith('.md')) continue;
if (name === INDEX_FILE) continue;
const id = name.slice(0, -3);
if (!/^[a-z0-9_]+$/.test(id)) continue;
try {
const filePath = path.join(dir, name);
const [raw, stat] = await Promise.all([
fsp.readFile(filePath, 'utf8'),
fsp.stat(filePath),
]);
const { summary } = summarize(id, raw, stat.mtimeMs);
out.push(summary);
} catch {
// Skip unreadable / malformed files; never let one bad file
// shadow the rest of the listing.
continue;
}
}
out.sort((a, b) => b.updatedAt - a.updatedAt);
return out;
}
export async function readMemoryEntry(dataDir, id) {
let raw;
let stat;
try {
const filePath = entryPath(dataDir, id);
[raw, stat] = await Promise.all([
fsp.readFile(filePath, 'utf8'),
fsp.stat(filePath),
]);
} catch {
return null;
}
const { summary, body } = summarize(id, raw, stat.mtimeMs);
return { ...summary, body };
}
function renderEntryFile(name, description, type, body) {
const safeName = String(name || 'Untitled').replace(/\r?\n/g, ' ').trim();
const safeDesc = String(description || '').replace(/\r?\n/g, ' ').trim();
const safeType = isValidType(type) ? type : 'user';
const trimmedBody = String(body || '').replace(/^\s+/, '');
return `---\nname: ${safeName}\ndescription: ${safeDesc}\ntype: ${safeType}\n---\n\n${trimmedBody}\n`;
}
export async function upsertMemoryEntry(dataDir, input, options) {
const { name, description, type, body } = input || {};
if (!name || !isValidType(type)) {
throw new Error('memory entry requires `name` and a valid `type`');
}
const id = input?.id && /^[a-z0-9_]+$/.test(input.id)
? input.id
: deriveMemoryId(type, name);
await ensureDir(memoryDir(dataDir));
await fsp.writeFile(
entryPath(dataDir, id),
renderEntryFile(name, description, type, body),
);
await ensureIndexHasEntry(dataDir, id, name, description);
const entry = await readMemoryEntry(dataDir, id);
if (!entry) throw new Error('failed to read memory entry after write');
if (!options?.silent) {
emitChange({
kind: 'upsert',
id: entry.id,
name: entry.name,
description: entry.description,
type: entry.type,
source: options?.source ?? 'manual',
});
}
return entry;
}
export async function deleteMemoryEntry(dataDir, id) {
try {
await fsp.unlink(entryPath(dataDir, id));
} catch {
// Already gone — fine. Caller doesn't care.
}
await removeIndexLine(dataDir, id);
emitChange({ kind: 'delete', id });
}
// ----- Index maintenance --------------------------------------------------
const INDEX_LINK_RE = /^\s*-\s+\[([^\]]+)\]\(([^)]+)\)(\s+—\s+(.*))?$/;
// Pull the linked entry ids out of MEMORY.md. The index is the user's
// editable list — every bullet that points at `<id>.md` is a fact the
// user wants injected into future system prompts. Removing a bullet
// disables that fact while leaving the underlying file on disk, so the
// user can paste the line back later. Anything that doesn't parse as a
// valid `<id>.md` link is ignored (free-form prose, headings, blank
// lines, `MEMORY.md` itself).
function parseIndexLinkIds(indexBody: string): Set<string> {
const ids = new Set<string>();
for (const line of String(indexBody ?? '').split(/\r?\n/)) {
const m = INDEX_LINK_RE.exec(line);
if (!m) continue;
const target = typeof m[2] === 'string' ? m[2] : '';
if (!target.endsWith('.md')) continue;
if (target === INDEX_FILE) continue;
const id = target.slice(0, -3);
if (/^[a-z0-9_]+$/.test(id)) ids.add(id);
}
return ids;
}
async function ensureIndexHasEntry(dataDir, id, name, description) {
const current = await readMemoryIndex(dataDir);
const lines = current.split(/\r?\n/);
const link = `${id}.md`;
const desc = String(description || '').replace(/\r?\n/g, ' ').trim();
const newLine = desc
? `- [${name}](${link}) — ${desc}`
: `- [${name}](${link})`;
let replaced = false;
for (let i = 0; i < lines.length; i++) {
const m = INDEX_LINK_RE.exec(lines[i] ?? '');
if (m && m[2] === link) {
lines[i] = newLine;
replaced = true;
break;
}
}
if (!replaced) {
if (lines.length > 0 && lines[lines.length - 1] !== '') lines.push('');
lines.push(newLine);
}
// Silent: the upsert path emits its own change event, so a redundant
// 'index' event would just cause the frontend to re-fetch twice.
await writeMemoryIndex(dataDir, lines.join('\n'), { silent: true });
}
async function removeIndexLine(dataDir, id) {
const current = await readMemoryIndex(dataDir);
const link = `${id}.md`;
const lines = current.split(/\r?\n/).filter((line) => {
const m = INDEX_LINK_RE.exec(line);
return !m || m[2] !== link;
});
await writeMemoryIndex(dataDir, lines.join('\n'), { silent: true });
}
// ----- System-prompt body -------------------------------------------------
// Build the markdown block that the prompt composer folds into every
// run. Returns `''` when memory is disabled, missing, or empty so the
// composer can drop the block without an extra `if`.
//
// Active set is derived from MEMORY.md's link bullets, NOT from every
// `*.md` file in the directory. The user's hand-edited index is the
// source of truth for which facts get injected: removing a `- [Name](id.md)`
// line disables that fact in future prompts while keeping the file on
// disk (paste the line back in the settings panel to re-enable it).
// Without this filter, deleted index lines had no effect — the daemon
// kept reading every entry file and the index editor was cosmetic only.
export async function composeMemoryBody(dataDir) {
const cfg = await readMemoryConfig(dataDir);
if (!cfg.enabled) return '';
const allEntries = await listMemoryEntries(dataDir);
if (allEntries.length === 0) return '';
const indexBody = await readMemoryIndex(dataDir);
const linkedIds = parseIndexLinkIds(indexBody);
const entries = allEntries.filter((e) => linkedIds.has(e.id));
if (entries.length === 0) return '';
const grouped = new Map();
for (const e of entries) {
const list = grouped.get(e.type) ?? [];
list.push(e);
grouped.set(e.type, list);
}
const ordered = ['user', 'feedback', 'project', 'reference']
.filter((t) => grouped.has(t));
const parts = [];
for (const type of ordered) {
parts.push(`### ${capitalize(type)}`);
for (const e of grouped.get(type) ?? []) {
const body = await readEntryBodyById(dataDir, e.id);
if (!body) continue;
parts.push(`- **${e.name}** — ${e.description || '(no description)'}`);
const indented = body
.trim()
.split(/\r?\n/)
.map((l) => ` ${l}`)
.join('\n');
if (indented.length > 0) parts.push(indented);
}
parts.push('');
}
return parts.join('\n').trim();
}
async function readEntryBodyById(dataDir, id) {
const entry = await readMemoryEntry(dataDir, id);
return entry?.body ?? '';
}
function capitalize(s) {
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
}
// ----- Heuristic auto-extraction -----------------------------------------
// Look for explicit "save this" markers in the user message. The aim is
// not to be a clever miner — that's what an LLM extractor is for. The
// aim is to make `/记住 X` and `remember: X` actually do something
// without spinning up a second model call. Returns an array of upserts
// applied (caller can surface them in the SSE stream / log).
//
// Each pattern is responsible for declaring the *shape* of the entry it
// produces — not just the regex. That way a Chinese "我是 X" capture
// gets a stable, human-readable label like `用户身份` with body
// `- 身份 / 角色X` instead of three identical fields all set to the
// raw captured phrase ("一名软件工程师"). The regex captures `$1` once;
// the templates below decide how that phrase becomes a useful memory.
//
// Fields per pattern:
// re — regex with exactly one capture group
// type — memory type bucket (`user` / `feedback` / …)
// name — stable label used as the entry's display name
// descriptionTemplate — short summary; `$1` is the captured phrase
// bodyTemplate — markdown body injected into future system
// prompts; `$1` is the captured phrase
//
// Id derivation: each captured fact gets a unique id derived from
// `(type, capturedPhrase)` so two distinct "我是" matches (e.g. user
// name vs. role) live in separate files instead of clobbering each
// other under one shared `用户身份` slot.
interface ExtractionPattern {
re: RegExp;
type: 'user' | 'feedback' | 'project' | 'reference';
name: string;
descriptionTemplate: string;
bodyTemplate: string;
}
const REMEMBER_PATTERNS: ExtractionPattern[] = [
// English
{
re: /(?:^|\b)(?:please\s+)?remember(?:\s+that)?[:\s]+([^\n]{4,400})/i,
type: 'feedback',
name: 'Remembered note',
descriptionTemplate: 'User asked to remember: $1',
bodyTemplate:
'- Remembered: $1\n\nWhen to apply: keep this in mind for future replies.',
},
{
re: /(?:^|\b)note\s+to\s+self[:\s]+([^\n]{4,400})/i,
type: 'feedback',
name: 'Note to self',
descriptionTemplate: 'Note: $1',
bodyTemplate: '- Note: $1',
},
{
re: /(?:^|\b)i(?:'m|\s+am)\s+(?:a|an|the)\s+([^.\n]{3,200})/i,
type: 'user',
name: 'User role',
descriptionTemplate: 'User is a $1',
bodyTemplate:
'- Role / identity: $1\n\nWhen to apply: any chat — frame examples and recommendations around this background.',
},
{
re: /(?:^|\b)i\s+prefer\s+([^.\n]{3,200})/i,
type: 'feedback',
name: 'User preference',
descriptionTemplate: 'User prefers $1',
bodyTemplate:
'- Preference: $1\n\nWhen to apply: factor this in whenever a relevant choice comes up.',
},
// "I'm in Berlin", "I live in Amsterdam", "I'm based in Lisbon" — pin
// the user's location so future replies can localise time/currency/
// tone without re-asking. We deliberately exclude the role pattern
// ("I am a/an/the …") so this doesn't double-fire on the same line.
{
re: /(?:^|\b)i(?:'m|\s+am)\s+(?:in|based\s+in|located\s+in|living\s+in)\s+([^.\n,]{2,80})/i,
type: 'user',
name: 'User location',
descriptionTemplate: 'User is based in $1',
bodyTemplate:
'- Location: $1\n\nWhen to apply: localise time-of-day phrasing, currency, and cultural references.',
},
{
re: /(?:^|\b)i\s+live\s+in\s+([^.\n,]{2,80})/i,
type: 'user',
name: 'User location',
descriptionTemplate: 'User lives in $1',
bodyTemplate:
'- Location: $1\n\nWhen to apply: localise time-of-day phrasing, currency, and cultural references.',
},
// "I want to ship a course", "I'd like to redesign the dashboard" —
// long-running goals that change how the assistant frames every
// related ask. Capped at 200 chars so a runaway sentence doesn't blow
// up the body.
{
re: /(?:^|\b)i(?:'d\s+like|\s+would\s+like|\s+want|\s+wanna|\s+hope)\s+to\s+([^.\n]{4,200})/i,
type: 'project',
name: 'User goal',
descriptionTemplate: 'User wants to $1',
bodyTemplate:
'- Goal: $1\n\nWhen to apply: surface relevance to this goal whenever the conversation drifts close to it.',
},
// Chinese
{
re: /记住[:\s]+([^\n。]{2,200})/,
type: 'feedback',
name: '重要备忘',
descriptionTemplate: '用户要求记住:$1',
bodyTemplate: '- 备忘:$1\n\n何时适用在后续对话里始终保持这一前提。',
},
{
re: /我是\s*([^\n。]{2,80})/,
type: 'user',
name: '用户身份',
descriptionTemplate: '用户的身份/职业:$1',
bodyTemplate:
'- 身份 / 角色:$1\n\n何时适用在所有对话里把用户的背景纳入考虑举例、措辞、深度。',
},
{
re: /我喜欢\s*([^\n。]{2,200})/,
type: 'feedback',
name: '用户偏好',
descriptionTemplate: '用户喜欢:$1',
bodyTemplate: '- 偏好:$1\n\n何时适用在涉及该选择时优先采用这一倾向。',
},
{
re: /我偏好\s*([^\n。]{2,200})/,
type: 'feedback',
name: '用户偏好',
descriptionTemplate: '用户偏好:$1',
bodyTemplate: '- 偏好:$1\n\n何时适用在涉及该选择时优先采用这一倾向。',
},
// 我在 / 我住在 — 用户所在地。用 [在再] 同时容忍输入法常见的把
// "在" 错按成 "再" 的拼写:用户原文 "我再德国,我希望基于…" 在过去
// 的 pattern 表里没有任何匹配,于是只能等 LLM 兜底;现在两条都直接
// 命中所在地与目标。
{
re: /我[在再]\s*([^\n。!?,]{2,80})/,
type: 'user',
name: '用户所在地',
descriptionTemplate: '用户所在地:$1',
bodyTemplate:
'- 所在地:$1\n\n何时适用在时区、货币、文化语境相关的回答里把这一点纳入考虑。',
},
{
re: /我住在\s*([^\n。!?,]{2,80})/,
type: 'user',
name: '用户所在地',
descriptionTemplate: '用户居住在:$1',
bodyTemplate:
'- 所在地:$1\n\n何时适用在时区、货币、文化语境相关的回答里把这一点纳入考虑。',
},
// 我想 / 我希望 / 我打算 — 长期目标,常常贯穿多次对话。和"记住"
// 这种命令式不同,这些表述往往伴随项目本身,所以归到 project 类。
{
re: /我(?:想|希望|打算|计划)\s*([^\n。!?]{4,200})/,
type: 'project',
name: '用户目标',
descriptionTemplate: '用户希望:$1',
bodyTemplate:
'- 目标:$1\n\n何时适用当对话靠近这一目标时主动呼应它并把建议与目标对齐。',
},
{
re: /备忘[:\s]+([^\n]{2,200})/,
type: 'reference',
name: '速记备忘',
descriptionTemplate: '$1',
bodyTemplate: '- $1',
},
];
function applyTemplate(template, captured) {
return String(template || '').replace(/\$1/g, String(captured));
}
export async function extractFromMessage(dataDir, userMessage) {
// Mirror the LLM extractor's skip surface so the settings panel shows
// both extractors for the same turn — even when there's nothing to
// record. Without this, a turn with memory disabled or an empty
// message produces no row at all and the user can't tell whether the
// hook ran.
if (typeof userMessage !== 'string' || userMessage.trim().length === 0) {
recordSkip({ userMessage: userMessage ?? '', reason: 'empty-message', kind: 'heuristic' });
return [];
}
const cfg = await readMemoryConfig(dataDir);
if (!cfg.enabled) {
recordSkip({ userMessage, reason: 'memory-disabled', kind: 'heuristic' });
return [];
}
const seen = new Set();
const changed = [];
for (const pattern of REMEMBER_PATTERNS) {
const m = pattern.re.exec(userMessage);
if (!m) continue;
const captured = (m[1] || '').trim();
if (captured.length < 3) continue;
// Cap captured length so a runaway sentence doesn't blow up the
// description / body. The regex already bounds it but we want a
// hard ceiling for the templated fields.
const trimmedCaptured = truncate(captured, 200);
// Dedupe within a single message: same category + same captured
// phrase shouldn't fire twice (two patterns matching the same
// chunk, or the regex matching a phrase that already passed an
// earlier pattern in this loop).
const dedupeKey = `${pattern.type}::${pattern.name}::${trimmedCaptured.toLowerCase()}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const description = truncate(
applyTemplate(pattern.descriptionTemplate, trimmedCaptured),
200,
);
const body = applyTemplate(pattern.bodyTemplate, trimmedCaptured);
// Each captured fact gets its own file. Deriving the id from the
// captured phrase (rather than the stable display name) lets two
// "我是" matches — e.g. "我是张三" then "我是软件工程师" — coexist
// instead of overwriting one another.
const id = deriveMemoryId(pattern.type, trimmedCaptured);
try {
const entry = await upsertMemoryEntry(
dataDir,
{
id,
type: pattern.type,
name: pattern.name,
description,
body,
},
// Silence the per-entry upsert event so the batched 'extract'
// emit below produces exactly one frontend toast.
{ silent: true, source: 'heuristic' },
);
changed.push({
id: entry.id,
name: entry.name,
description: entry.description,
type: entry.type,
updatedAt: entry.updatedAt,
});
} catch (err) {
console.warn('[memory] auto-extract write failed', err);
}
}
if (changed.length > 0) {
emitChange({
kind: 'extract',
count: changed.length,
source: 'heuristic',
});
}
// Always log the heuristic attempt — even when no pattern matched —
// so the settings panel's "Extraction history" shows a row for every
// turn instead of leaving the user wondering whether the regex ran.
// 0-match runs land as `phase: 'skipped'` with reason `'no-match'`.
recordHeuristic({
userMessage,
writtenCount: changed.length,
writtenIds: changed.map((c) => c.id),
});
return changed;
}
function truncate(s, max) {
if (s.length <= max) return s;
return `${s.slice(0, max - 1).trim()}`;
}

View file

@ -102,6 +102,12 @@ export interface ComposeInput {
// (letter-spacing, accent caps, anti-slop) cover everything below.
craftBody?: string | undefined;
craftSections?: string[] | undefined;
// Markdown built from the user's auto-memory store
// (<dataDir>/memory/*.md). Folded in before the active design system so
// tone/voice/preferences extracted from past chats win over the
// built-in identity charter but still defer to the brand's hard tokens
// and the active skill's workflow. Empty/undefined skips the block.
memoryBody?: string | undefined;
// Project-level metadata captured by the new-project panel. Drives the
// agent's understanding of artifact kind, fidelity, speaker-notes intent
// and animation intent. Missing fields here are exactly what the
@ -143,6 +149,7 @@ export function composeSystemPrompt({
designSystemTitle,
craftBody,
craftSections,
memoryBody,
metadata,
template,
critique,
@ -161,6 +168,12 @@ export function composeSystemPrompt({
BASE_SYSTEM_PROMPT,
];
if (memoryBody && memoryBody.trim().length > 0) {
parts.push(
`\n\n## Personal memory (auto-extracted from past chats)\n\nThe following facts have been sedimented from this user's previous conversations and edited in the settings panel. Treat them as preferences and context, NOT hard rules: when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins. They are still authoritative for tone, voice, terminology, and what the user already told you about themselves and their goals — never re-ask the user about something already captured here.\n\n${memoryBody.trim()}`,
);
}
if (designSystemBody && designSystemBody.trim().length > 0) {
parts.push(
`\n\n## Active design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${designSystemBody.trim()}`,

View file

@ -38,6 +38,26 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js';
import {
composeMemoryBody,
deleteMemoryEntry,
extractFromMessage,
listMemoryEntries,
maskMemoryExtractionConfig,
memoryDir,
memoryEvents,
readMemoryConfig,
readMemoryEntry,
readMemoryIndex,
upsertMemoryEntry,
writeMemoryConfig,
writeMemoryIndex,
} from './memory.js';
import {
clearExtractions as clearMemoryExtractions,
listExtractions as listMemoryExtractions,
removeExtraction as removeMemoryExtraction,
} from './memory-extractions.js';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
@ -2214,6 +2234,317 @@ export async function startServer({
// ---- Projects (DB-backed) -------------------------------------------------
// ----- Memory store -----------------------------------------------------
// Markdown-on-disk memory under <dataDir>/memory/. The daemon folds these
// into every system prompt (gated by `enabled`) and the chat run loop
// calls `/api/memory/extract` after each turn to sediment new facts.
app.get('/api/memory', async (_req, res) => {
try {
const [config, index, entries] = await Promise.all([
readMemoryConfig(RUNTIME_DATA_DIR),
readMemoryIndex(RUNTIME_DATA_DIR),
listMemoryEntries(RUNTIME_DATA_DIR),
]);
res.json({
enabled: config.enabled,
rootDir: memoryDir(RUNTIME_DATA_DIR),
index,
entries,
extraction: maskMemoryExtractionConfig(config.extraction),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Static sub-resources (`/index`, `/config`, `/extract`) registered
// BEFORE the `:id` catch-alls so an `index` / `config` / `extract` slug
// can't shadow the real handlers.
app.put('/api/memory/index', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const index = typeof body.index === 'string' ? body.index : '';
await writeMemoryIndex(RUNTIME_DATA_DIR, index);
res.json({ index });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.patch('/api/memory/config', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const patch = {};
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled;
// 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
// (`extraction: { provider, ... }`). For the apiKey field we
// need *four* states because the masked GET surfaces only an
// `apiKeyTail` (the secret never round-trips):
// - field absent → preserve the stored key (UI re-saves
// a settings form without re-typing
// the secret).
// - field === '' → CLEAR the stored key (the picker's
// drift-resync effect fires this when
// the user clears their BYOK chat
// API key — keeping the old daemon-
// side credential would silently keep
// calling the provider after the user
// intentionally removed it from the
// chat picker, which the reviewer
// flagged as a credential-sync bug).
// - field === 'sk-…' → replace with the new key.
// - provider differs → ignore stored key entirely.
if (Object.prototype.hasOwnProperty.call(body, 'extraction')) {
if (body.extraction === null) {
patch.extraction = null;
} else if (body.extraction && typeof body.extraction === 'object') {
const incoming = body.extraction;
const current = await readMemoryConfig(RUNTIME_DATA_DIR);
const apiKeyOmitted = !Object.prototype.hasOwnProperty.call(
incoming,
'apiKey',
);
const sameProvider =
!!current.extraction
&& current.extraction.provider === incoming.provider;
let nextApiKey = '';
if (typeof incoming.apiKey === 'string' && incoming.apiKey) {
nextApiKey = incoming.apiKey;
} else if (apiKeyOmitted && sameProvider) {
nextApiKey = current.extraction.apiKey ?? '';
}
patch.extraction = {
provider: incoming.provider,
model:
typeof incoming.model === 'string' ? incoming.model : undefined,
baseUrl:
typeof incoming.baseUrl === 'string'
? incoming.baseUrl
: undefined,
apiKey: nextApiKey,
// Azure-only; ignored by the validator for the other providers.
// We forward whatever the UI sent (or the previously-stored
// value when the UI omits the field) so re-saving an azure
// override without re-typing the api-version doesn't blank it.
apiVersion:
typeof incoming.apiVersion === 'string'
? incoming.apiVersion
: current.extraction?.apiVersion,
};
}
}
const next = await writeMemoryConfig(RUNTIME_DATA_DIR, patch);
res.json({
enabled: next.enabled,
extraction: maskMemoryExtractionConfig(next.extraction),
});
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
// SSE feed of memory mutations. The web settings panel subscribes to
// this and re-fetches on every event; toast UIs can listen for
// `kind === 'extract'` and surface a small "Memory updated (N new)"
// notification. Payload shape: MemoryChangeEvent (see ./memory.ts).
//
// The same connection also forwards `extraction` events — one per LLM
// extraction phase transition — so the settings panel can render a
// live "recent extractions" list. We multiplex on a single SSE stream
// so the browser opens one connection instead of two.
app.get('/api/memory/events', async (_req, res) => {
const sse = createSseResponse(res);
sse.send('connected', { at: Date.now() });
const onChange = (event) => {
sse.send('change', event);
};
const onExtraction = (event) => {
sse.send('extraction', event);
};
memoryEvents.on('change', onChange);
memoryEvents.on('extraction', onExtraction);
res.on('close', () => {
memoryEvents.off('change', onChange);
memoryEvents.off('extraction', onExtraction);
});
});
// Recent LLM-extraction attempts (newest first; capped server-side).
// Surfaces skip reasons, in-flight calls, success counts, and errors
// so the settings panel can show "why didn't memory update?" at a
// glance instead of leaving the user to guess.
app.get('/api/memory/extractions', async (_req, res) => {
try {
res.json({ extractions: listMemoryExtractions() });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Drop the entire extraction history. Registered BEFORE the `:id`
// catch-all so a literal "/api/memory/extractions" can still be
// cleared with `curl -X DELETE`.
app.delete('/api/memory/extractions', async (_req, res) => {
try {
const removed = clearMemoryExtractions();
res.json({ removed });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
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) });
}
});
// Imperative extract — used by CLI chats internally and by BYOK /
// API-mode chats from the web app, which never reach the chat-run
// path on the daemon. Mirrors the two-phase hook the daemon's chat
// route applies inline:
//
// - Pre-turn (only `userMessage` supplied): run the synchronous
// heuristic regex pack so explicit "remember: X" / "我是 X"
// markers land in memory before the prompt is composed, and the
// same turn's assistant reply already reflects them.
// - Post-turn (`userMessage` + `assistantMessage` supplied): queue
// the LLM extractor in the background — it speaks SSE /
// extraction-history on its own and may take several seconds, so
// we don't block the HTTP response on it. The heuristic is
// skipped on this branch because the caller already ran it
// pre-turn; running it twice would double the
// `recordHeuristic({...})` rows in the extraction history for
// every turn.
//
// External callers (curl, replay tools) that pass only
// `userMessage` keep the legacy behaviour: heuristic-only.
app.post('/api/memory/extract', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const userMessage =
typeof body.userMessage === 'string' ? body.userMessage : '';
const assistantMessage =
typeof body.assistantMessage === 'string' ? body.assistantMessage : '';
const hasAssistant = assistantMessage.trim().length > 0;
const changed = hasAssistant
? []
: await extractFromMessage(RUNTIME_DATA_DIR, userMessage);
// BYOK chat config — only forwarded by the web app for API-mode
// chats. We strip the surface to the five fields pickProvider()
// actually consumes and validate the provider against the four
// shapes the extractor speaks; an unknown / missing provider
// means "let the legacy chain decide" so a malformed payload
// can't override the env / media-config fallbacks.
const rawChat = body.chatProvider;
let chatProvider = null;
if (rawChat && typeof rawChat === 'object') {
const provider = rawChat.provider;
if (
provider === 'anthropic'
|| provider === 'openai'
|| provider === 'azure'
|| provider === 'google'
|| provider === 'ollama'
) {
chatProvider = {
provider,
apiKey: typeof rawChat.apiKey === 'string' ? rawChat.apiKey : '',
baseUrl: typeof rawChat.baseUrl === 'string' ? rawChat.baseUrl : '',
apiVersion:
typeof rawChat.apiVersion === 'string' ? rawChat.apiVersion : '',
model: typeof rawChat.model === 'string' ? rawChat.model : '',
};
}
}
let attemptedLLM = false;
if (userMessage.trim().length > 0 && hasAssistant) {
attemptedLLM = true;
void import('./memory-llm.js')
.then(({ extractWithLLM }) =>
extractWithLLM(
RUNTIME_DATA_DIR,
{ userMessage, assistantMessage },
{
projectRoot: PROJECT_ROOT,
chatAgentId: null,
chatProvider,
},
),
)
.catch((err) =>
console.warn('[memory-llm] background failed (http extract)', err),
);
}
res.json({ changed, attemptedLLM });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
// Composed memory body for the system prompt. Daemon-side chat runs
// call `composeMemoryBody()` directly; the web app (BYOK / API mode)
// can't import daemon internals, so this endpoint exposes the same
// string the daemon would have folded into the system prompt for a
// CLI run. `ProjectView.composedSystemPrompt()` calls it before each
// BYOK turn and passes the result into `composeSystemPrompt`'s
// `memoryBody` field — without this, the Memory tab is a no-op for
// BYOK users even though the UI saves model/index/entries for them.
app.get('/api/memory/system-prompt', async (_req, res) => {
try {
const body = await composeMemoryBody(RUNTIME_DATA_DIR);
res.json({ body });
} catch (err) {
res.status(500).json({ error: String((err && err.message) || err) });
}
});
app.post('/api/memory', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const entry = await upsertMemoryEntry(RUNTIME_DATA_DIR, body);
res.json({ entry });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.get('/api/memory/:id', async (req, res) => {
try {
const entry = await readMemoryEntry(RUNTIME_DATA_DIR, req.params.id);
if (!entry) return res.status(404).json({ error: 'memory not found' });
res.json({ entry });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.put('/api/memory/:id', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const entry = await upsertMemoryEntry(RUNTIME_DATA_DIR, {
...body,
id: req.params.id,
});
res.json({ entry });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
app.delete('/api/memory/:id', async (req, res) => {
try {
await deleteMemoryEntry(RUNTIME_DATA_DIR, req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: String((err && err.message) || err) });
}
});
const design = {
runs: createChatRunService({ createSseResponse, createSseErrorPayload }),
@ -2592,6 +2923,17 @@ export async function startServer({
}
}
// Personal-memory body is always recomputed at compose time so a
// memory the user just edited in settings shows up on the very next
// run. composeMemoryBody returns '' when memory is disabled or
// empty; the composer drops the block on a falsy value.
let memoryBody = '';
try {
memoryBody = await composeMemoryBody(RUNTIME_DATA_DIR);
} catch (err) {
console.warn('[memory] composeMemoryBody failed', err);
}
let designSystemBody;
let designSystemTitle;
if (effectiveDesignSystemId) {
@ -2664,6 +3006,7 @@ export async function startServer({
designSystemTitle,
craftBody,
craftSections,
memoryBody,
metadata,
template,
critique: critiqueShouldRun ? critiqueCfg : undefined,
@ -2743,6 +3086,21 @@ export async function startServer({
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
const runId = run.id;
// Auto-memory hook. Pulls explicit "remember:" / "我是 X" / "I prefer Y"
// markers out of the just-arrived user message and writes them as MD
// files under <dataDir>/memory/. We await so the very next
// composeSystemPrompt() call (a few lines below) re-reads memory from
// disk and a marker inside this turn's message is reflected in this
// turn's prompt. Failures are swallowed — memory is best-effort and
// must never block the agent run.
if (typeof message === 'string' && message.trim().length > 0) {
try {
await extractFromMessage(RUNTIME_DATA_DIR, message);
} catch (err) {
console.warn('[memory] extractFromMessage failed', err);
}
}
// Resolve the project working directory (creating the folder if it
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
@ -3375,6 +3733,45 @@ export async function startServer({
// long time in non-streamed reasoning still keep the run alive.
child.stdout.on('data', () => noteAgentActivity());
// ---- Memory: assistant-reply buffer for LLM extraction --------------
// Capture up to 32 KiB of raw stdout. The LLM extractor (fired in the
// close handler) trims further; we only need enough to ground the
// model. Multiple `on('data')` listeners coexist — the wrapper-stream
// handlers below also subscribe and that's fine.
const MEMORY_BUFFER_CAP = 32 * 1024;
let memoryAssistantBuffer = '';
child.stdout.on('data', (chunk) => {
if (memoryAssistantBuffer.length >= MEMORY_BUFFER_CAP) return;
memoryAssistantBuffer += String(chunk);
if (memoryAssistantBuffer.length > MEMORY_BUFFER_CAP) {
memoryAssistantBuffer = memoryAssistantBuffer.slice(0, MEMORY_BUFFER_CAP);
}
});
child.on('close', () => {
const captured = memoryAssistantBuffer;
const userMsg = typeof message === 'string' ? message : '';
// Forward the chat agent id so memory-llm.pickProvider can
// constrain its auto-pick to the chat protocol's family — keeps
// a Claude Code (anthropic) chat from triggering OpenAI/gpt-4o-
// mini extraction in the background just because the user has
// an OpenAI key parked in media-config.
void import('./memory-llm.js')
.then(({ extractWithLLM }) =>
extractWithLLM(
RUNTIME_DATA_DIR,
{
userMessage: userMsg,
assistantMessage: captured,
},
{
projectRoot: PROJECT_ROOT,
chatAgentId: typeof agentId === 'string' ? agentId : null,
},
),
)
.catch((err) => console.warn('[memory-llm] background failed', err));
});
// Critique Theater branch (M0 dark launch, default disabled).
// Only plain-stream adapters are routed through runOrchestrator in v1.
// Adapters that emit structured wrappers (claude-stream-json,

View file

@ -0,0 +1,157 @@
// Coverage for PATCH /api/memory/config apiKey three-state handling.
//
// MemoryModelInline now silently re-PATCHes whenever the surrounding BYOK
// chat creds drift, so the route must distinguish:
// - apiKey field absent → preserve the stored secret (settings re-save
// without re-typing the key)
// - apiKey === '' → CLEAR the stored secret (the user removed
// their chat key; we must not keep calling
// the provider with the stale credential)
// - apiKey === 'sk-…' → replace with the new key
import type http from 'node:http';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
memoryDir,
readMemoryConfig,
writeMemoryConfig,
} from '../src/memory.js';
import { startServer } from '../src/server.js';
interface StartedServer {
url: string;
server: http.Server;
}
let baseUrl: string;
let server: http.Server;
const dataDir = process.env.OD_DATA_DIR as string;
async function patchConfig(body: unknown): Promise<Response> {
return fetch(`${baseUrl}/api/memory/config`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
async function readStoredExtraction(): Promise<Record<string, unknown> | null> {
const stored = (await readMemoryConfig(dataDir)) as {
extraction: Record<string, unknown> | null;
};
return stored.extraction;
}
beforeAll(async () => {
const started = (await startServer({
port: 0,
returnServer: true,
})) as StartedServer;
baseUrl = started.url;
server = started.server;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
beforeEach(async () => {
await fsp.rm(path.join(memoryDir(dataDir), 'config.json'), { force: true });
});
describe('PATCH /api/memory/config apiKey three-state handling', () => {
it('preserves stored apiKey when the patch omits the field entirely', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
baseUrl: 'https://api.openai.com',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.apiKey).toBe('sk-stored-secret');
});
it('clears the stored apiKey when the patch sends an explicit empty string', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
baseUrl: 'https://api.openai.com',
apiKey: '',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.apiKey ?? '').toBe('');
});
it('replaces the stored apiKey when the patch sends a new value', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-old-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
baseUrl: 'https://api.openai.com',
apiKey: 'sk-new-secret',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.apiKey).toBe('sk-new-secret');
});
it('does not reuse the stored apiKey when the provider changes', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-openai-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: {
provider: 'anthropic',
model: 'claude-haiku-4-5',
baseUrl: 'https://api.anthropic.com',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('anthropic');
expect(extraction?.apiKey ?? '').toBe('');
});
});

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { EntryView } from './components/EntryView';
import type { CreateInput } from './components/NewProjectPanel';
import { MemoryToast } from './components/MemoryToast';
import { PetOverlay } from './components/pet/PetOverlay';
import { migrateCustomPetAtlas } from './components/pet/pets';
import { ProjectView } from './components/ProjectView';
@ -894,6 +895,7 @@ export function App() {
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
/>
) : null}
<MemoryToast onOpenMemory={() => openSettings('memory')} />
{/* First-run privacy consent banner. It waits for daemon config
hydration because privacyDecisionAt is daemon-owned and stripped
from localStorage. It also yields while Settings is open so the

View file

@ -0,0 +1,470 @@
// Inline "Memory model" picker — sits right next to the chat model
// dropdown (both in CLI mode and BYOK mode) inside Settings →
// Configure execution mode.
//
// Why one tiny dropdown instead of a separate panel:
// User feedback was explicit — the memory extractor isn't a parallel
// 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.
//
// 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.
// - "Custom..." sentinel: opens a free-text input. Same persistence
// as the suggested branch.
//
// Persistence: PATCH /api/memory/config. The daemon stores the chosen
// override under <dataDir>/memory/.config.json and reads it on every
// extraction attempt — a single PATCH propagates without a daemon
// restart.
import {
useCallback,
useEffect,
useId,
useMemo,
useState,
} from 'react';
import { useT } from '../i18n';
import type {
MemoryExtractionConfig as MemoryExtractionConfigShape,
MemoryExtractionMaskedConfig,
MemoryExtractionProvider,
MemoryListResponse,
} from '@open-design/contracts';
import type { ApiProtocol, ExecMode } from '../types';
import {
SUGGESTED_MODELS_BY_PROTOCOL,
} from '../state/apiProtocols';
import { CUSTOM_MODEL_SENTINEL } from './modelOptions';
interface Props {
mode: ExecMode;
// BYOK context (only meaningful when mode === 'api'). The picker
// copies these straight into the saved override so the daemon can
// call the API without a second round trip through the user.
apiProtocol: ApiProtocol;
chatApiKey: string;
chatBaseUrl: string;
chatApiVersion: string;
// The chat model is shown next to the "Same as chat" pill so the
// user can see what the auto-default picks today.
chatModel: string;
// CLI context (only meaningful when mode === 'daemon'). Used to seed
// the dropdown options with the same model list the chat picker
// shows for the selected agent.
cliModelOptions?: readonly string[];
// The currently-selected CLI agent id. Used to derive a chat
// protocol family in CLI mode (claude → anthropic, codex → openai,
// gemini → google, …) so the dropdown's "Same as chat" label can
// show the actual provider the daemon will call, and so the user
// sees a clear "needs an X API key" hint instead of being surprised
// when extraction silently lands on whatever foreign vendor key
// happens to be in media-config.
cliAgentId?: string | null;
}
// "No override" sentinel — distinct from CUSTOM_MODEL_SENTINEL so the
// reducer can switch between "clear override" and "let me type" cleanly.
const SAME_AS_CHAT_SENTINEL = '__same_as_chat__';
// Pattern-match a model id back to a provider/protocol. CLI mode has
// no surrounding ApiProtocol to lean on, so we read the prefix the
// same way the chat picker would: claude-* → Anthropic API, gemini-*
// → Google Gemini, everything else → OpenAI-compatible (the lingua
// franca of CLI agents — Codex, Qwen, DeepSeek, MiniMax all speak it).
function inferProviderFromModel(modelId: string): MemoryExtractionProvider {
const id = modelId.trim().toLowerCase();
if (id.startsWith('claude') || id.includes('/claude')) return 'anthropic';
if (id.startsWith('gemini') || id.includes('/gemini')) return 'google';
return 'openai';
}
// Map a CLI agent id to the API protocol family it speaks. Mirrors the
// daemon-side `chatProtocolFromAgentId()` in `memory-llm.ts` exactly —
// keep the two tables in sync so the UI's "Same as chat" label and the
// daemon's auto-pick agree on which provider memory will actually call.
function chatProtocolFromAgent(
agentId: string | null | undefined,
): MemoryExtractionProvider | null {
if (!agentId) return null;
const id = agentId.trim().toLowerCase();
if (id === 'claude') return 'anthropic';
if (id === 'gemini') return 'google';
if (
id === 'codex'
|| id === 'opencode'
|| id === 'qwen'
|| id === 'deepseek'
|| id === 'kimi'
|| id === 'copilot'
|| id === 'pi'
|| id === 'kiro'
|| id === 'kilo'
|| id === 'vibe'
|| id === 'devin'
|| id === 'hermes'
|| id === 'cursor-agent'
|| id === 'qoder'
) {
return 'openai';
}
return null;
}
async function fetchMemoryExtraction(): Promise<MemoryExtractionMaskedConfig | null> {
try {
const resp = await fetch('/api/memory');
if (!resp.ok) return null;
const json = (await resp.json()) as MemoryListResponse;
return json.extraction ?? null;
} catch {
return null;
}
}
async function saveMemoryExtraction(
extraction: MemoryExtractionConfigShape | null,
): Promise<MemoryExtractionMaskedConfig | null | undefined> {
const resp = await fetch('/api/memory/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extraction }),
});
if (!resp.ok) return undefined;
const json = (await resp.json()) as {
enabled: boolean;
extraction: MemoryExtractionMaskedConfig | null;
};
return json.extraction ?? null;
}
export function MemoryModelInline({
mode,
apiProtocol,
chatApiKey,
chatBaseUrl,
chatApiVersion,
chatModel,
cliModelOptions,
cliAgentId,
}: Props) {
const t = useT();
const [config, setConfig] = useState<MemoryExtractionMaskedConfig | null>(
null,
);
const [customEditing, setCustomEditing] = useState(false);
const [customDraft, setCustomDraft] = useState('');
const [busy, setBusy] = useState(false);
// Brief inline confirmation after Save / clear so the user knows
// their click did something even though the dropdown just settles.
const [flash, setFlash] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchMemoryExtraction().then((next) => {
if (cancelled) return;
setConfig(next);
if (next?.model) setCustomDraft(next.model);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!flash) return;
const id = setTimeout(() => setFlash(null), 1600);
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.
const effectiveChatProtocol: MemoryExtractionProvider | null =
mode === 'api' ? apiProtocol : chatProtocolFromAgent(cliAgentId);
const modelOptions = useMemo<readonly string[]>(() => {
if (mode === 'api') return SUGGESTED_MODELS_BY_PROTOCOL[apiProtocol];
return cliModelOptions ?? [];
}, [mode, apiProtocol, cliModelOptions]);
const savedModel = config?.model ?? '';
const savedInOptions =
Boolean(savedModel) && modelOptions.includes(savedModel);
const customActive = customEditing || (Boolean(savedModel) && !savedInOptions);
const selectValue = !savedModel
? SAME_AS_CHAT_SENTINEL
: customActive
? CUSTOM_MODEL_SENTINEL
: savedModel;
// Build the override payload to PATCH for a given model id.
// - 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.
const buildOverride = useCallback(
(modelId: string): MemoryExtractionConfigShape => {
const trimmedModel = modelId.trim();
if (mode === 'api') {
return {
provider: apiProtocol,
model: trimmedModel,
baseUrl: chatBaseUrl.trim(),
apiKey: chatApiKey,
apiVersion: apiProtocol === 'azure' ? chatApiVersion.trim() : '',
};
}
const provider =
chatProtocolFromAgent(cliAgentId)
?? inferProviderFromModel(trimmedModel);
return {
provider,
model: trimmedModel,
baseUrl: '',
apiKey: '',
apiVersion: '',
};
},
[mode, apiProtocol, chatApiKey, chatBaseUrl, chatApiVersion, cliAgentId],
);
const persist = useCallback(
async (
next: MemoryExtractionConfigShape | null,
options?: { silent?: boolean },
) => {
setBusy(true);
try {
const result = await saveMemoryExtraction(next);
if (result !== undefined) {
setConfig(result);
// Skip the "Saved!" flash on background re-syncs (provider
// tab swap, base-URL keystroke autosave, key rotation). The
// user didn't click anything here; flashing every keystroke
// would feel like the picker is "fighting" them.
if (!options?.silent) {
setFlash(
next === null
? t('settings.memoryModelInlineFlashCleared')
: t('settings.memoryModelInlineFlashSaved'),
);
}
}
} finally {
setBusy(false);
}
},
[t],
);
// Re-sync the saved memory override when the surrounding BYOK chat
// config drifts. The picker initially captures provider / key /
// baseUrl / apiVersion at click time, but if the user later swaps the
// protocol tab, rotates the API key, or edits the base URL, the
// background memory extractor would otherwise keep calling the *old*
// vendor / credential — directly contradicting the picker's "borrows
// the surrounding chat picker's protocol, key, base URL, and
// api-version automatically" promise.
//
// We compare the persisted (masked) shape against the live chat
// props field by field; the masked config exposes the last 4 chars
// of the saved key as `apiKeyTail`, which is enough to detect a
// rotation without ever round-tripping the secret back to the
// browser. A 300 ms debounce coalesces the keystroke-granularity
// prop updates that a parent autosave dialog typically streams in,
// so we don't spam PATCH /api/memory/config on every character.
useEffect(() => {
if (mode !== 'api') return;
if (busy) return;
if (customEditing) return;
if (!config || !config.model) return;
const trimmedBaseUrl = chatBaseUrl.trim();
const newTail = (chatApiKey || '').slice(-4);
const azureVersion = apiProtocol === 'azure' ? chatApiVersion.trim() : '';
const drift =
config.provider !== apiProtocol
|| config.baseUrl !== trimmedBaseUrl
|| config.apiVersion !== azureVersion
|| config.apiKeyTail !== newTail;
if (!drift) return;
const handle = setTimeout(() => {
void persist(buildOverride(config.model), { silent: true });
}, 300);
return () => clearTimeout(handle);
}, [
mode,
apiProtocol,
chatApiKey,
chatBaseUrl,
chatApiVersion,
config,
busy,
customEditing,
buildOverride,
persist,
]);
const onSelectChange = useCallback(
async (value: string) => {
if (value === SAME_AS_CHAT_SENTINEL) {
setCustomEditing(false);
setCustomDraft('');
await persist(null);
return;
}
if (value === CUSTOM_MODEL_SENTINEL) {
// Just open the input — don't PATCH yet. The user can type a
// model id and press the Save button below; clicking the
// dropdown again before saving collapses the input and reverts
// to the previous saved value.
setCustomEditing(true);
setCustomDraft(savedModel || '');
return;
}
setCustomEditing(false);
await persist(buildOverride(value));
},
[persist, buildOverride, savedModel],
);
const onSaveCustom = useCallback(async () => {
const trimmed = customDraft.trim();
if (!trimmed) return;
await persist(buildOverride(trimmed));
setCustomEditing(false);
}, [customDraft, persist, buildOverride]);
// CLI mode with no models advertised by the agent — fall back to a
// simple "Same as chat" / "Custom..." pair so the picker is still
// usable. (Some CLIs don't expose a `models` command; the chat
// picker shows the same fallback there.)
const showSuggestedOptions = modelOptions.length > 0;
// Stable unique id for the labelling span so multiple instances of
// this picker (or instances rendered alongside other Memory pickers)
// never collide on a global selector. The select uses
// `aria-labelledby` to point at *just* the short title — never the
// hint paragraph, never the flash status — so Playwright's
// `getByLabel('Memory model')` resolves to a single combobox and
// `getByLabel('API key' / 'Model')` on the surrounding chat form
// can't accidentally cross-match the hint copy here.
const labelId = useId();
// The wrapper used to be a <label>, which made the select's
// accessible name absorb every text descendant (the flash status,
// the hint paragraph). The reviewer asked for a non-label wrapper so
// the labelling element is just the short title; we now use a div
// with an explicit id-based association via `aria-labelledby`.
return (
<div className="field">
<span id={labelId} className="field-label">
{t('settings.memoryModelInlineLabel')}
</span>
{flash ? (
<span
role="status"
aria-live="polite"
style={{
display: 'inline-block',
marginLeft: 8,
marginTop: -2,
fontSize: 11,
fontWeight: 500,
color: 'var(--text-success, #1f7a3a)',
textTransform: 'none',
letterSpacing: 0,
}}
>
{flash}
</span>
) : null}
<select
aria-labelledby={labelId}
value={selectValue}
disabled={busy}
onChange={(e) => void onSelectChange(e.target.value)}
>
<option value={SAME_AS_CHAT_SENTINEL}>
{effectiveChatProtocol
? t('settings.memoryModelInlineSameAsChatWithProvider', {
provider: effectiveChatProtocol,
})
: chatModel
? t('settings.memoryModelInlineSameAsChatWithModel', {
model: chatModel,
})
: t('settings.memoryModelInlineSameAsChat')}
</option>
{showSuggestedOptions
? modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))
: null}
<option value={CUSTOM_MODEL_SENTINEL}>
{t('settings.modelCustom')}
</option>
</select>
{customActive ? (
<div
className="field-row"
style={{ marginTop: 6, display: 'flex', gap: 6 }}
>
<input
type="text"
aria-label={t('settings.memoryModelInlineLabel')}
value={customDraft}
placeholder={t('settings.modelCustomPlaceholder')}
onChange={(e) => setCustomDraft(e.target.value.trimStart())}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void onSaveCustom();
}
}}
/>
<button
type="button"
className="ghost"
onClick={() => void onSaveCustom()}
disabled={busy || !customDraft.trim()}
>
{t('common.save')}
</button>
</div>
) : null}
<p className="hint" style={{ marginTop: 4, fontSize: 11 }}>
{mode === 'api'
? t('settings.memoryModelInlineHintByok')
: effectiveChatProtocol
? t('settings.memoryModelInlineHintCliConstrained', {
provider: effectiveChatProtocol,
})
: t('settings.memoryModelInlineHintCli')}
</p>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,157 @@
// Floats above everything else and surfaces a transient "Memory updated"
// pill whenever the daemon emits a `kind: 'extract'` change event. We
// only fire on extraction events so a manual edit in the settings panel
// doesn't bounce a redundant toast back at the user (their click was the
// confirmation). The pill is clickable: tapping it opens Settings →
// Memory so the user can immediately see (and edit) the freshly
// extracted entries. The component owns its own EventSource so it can
// be dropped into App.tsx with no other plumbing.
import { useEffect, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import type { MemoryChangeEvent } from '@open-design/contracts';
import { useT } from '../i18n';
interface ActiveToast {
key: number;
count: number;
source?: MemoryChangeEvent['source'];
}
interface Props {
// Optional click handler. When provided, the pill becomes a button
// and clicking it should jump the user into Settings → Memory.
onOpenMemory?: () => void;
}
const VISIBLE_MS = 4500;
export function MemoryToast({ onOpenMemory }: Props) {
const t = useT();
const [toast, setToast] = useState<ActiveToast | null>(null);
// We keep the dismiss timer in a ref so a second event mid-flight
// resets the countdown instead of double-dismissing.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Guard for environments without EventSource (jsdom in tests, SSR).
// The toast is purely a UX nicety; no SSE just means no auto-pop-up.
if (typeof EventSource === 'undefined') return;
const es = new EventSource('/api/memory/events');
es.addEventListener('change', (raw) => {
try {
const event = JSON.parse((raw as MessageEvent).data) as MemoryChangeEvent;
if (event.kind !== 'extract') return;
if ((event.count ?? 0) <= 0) return;
// Source defaults to heuristic but a manual extract via curl
// would still be useful to surface. Only suppress when source is
// 'manual' (won't currently fire, reserved for future settings
// bulk-import hook).
if (event.source === 'manual') return;
setToast({
key: Date.now(),
count: event.count ?? 1,
source: event.source,
});
} catch {
// Malformed payload — ignore.
}
});
es.addEventListener('error', () => {
// The browser will auto-reconnect. We don't surface connection
// failures because the SSE channel is purely a UX nicety; missing
// a notification still lets the user see updates next time they
// open Settings → Memory.
});
return () => {
es.close();
};
}, []);
useEffect(() => {
if (!toast) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setToast(null), VISIBLE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast]);
if (!toast) return null;
const label = t('settings.memoryToastChanged');
const detail =
toast.source === 'llm'
? `(${toast.count} · LLM)`
: `(${toast.count})`;
const clickHint = t('settings.memoryToastClickHint');
// Reset native button styling. The pill needs to look identical to
// the previous div + carry button semantics so screen readers and
// keyboard users can activate it.
const pillStyle: CSSProperties = {
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 1000,
padding: '8px 14px',
borderRadius: 999,
background: 'rgba(20, 20, 20, 0.92)',
color: '#fff',
fontSize: 13,
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
display: 'flex',
alignItems: 'center',
gap: 8,
backdropFilter: 'blur(8px)',
border: 'none',
font: 'inherit',
cursor: onOpenMemory ? 'pointer' : 'default',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
};
if (!onOpenMemory) {
return (
<div role="status" aria-live="polite" style={pillStyle}>
<span aria-hidden style={{ fontSize: 14 }}></span>
<span>{label}</span>
<span style={{ opacity: 0.65 }}>{detail}</span>
</div>
);
}
return (
<button
type="button"
aria-live="polite"
aria-label={`${label} ${detail}${clickHint}`}
title={clickHint}
onClick={onOpenMemory}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 10px 28px rgba(0,0,0,0.24)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(0,0,0,0.18)';
}}
style={pillStyle}
>
<span aria-hidden style={{ fontSize: 14 }}></span>
<span>{label}</span>
<span style={{ opacity: 0.65 }}>{detail}</span>
<span
aria-hidden
style={{
marginLeft: 4,
paddingLeft: 8,
borderLeft: '1px solid rgba(255,255,255,0.18)',
opacity: 0.85,
fontSize: 12,
fontWeight: 500,
}}
>
{clickHint}
</span>
</button>
);
}

View file

@ -31,7 +31,11 @@ import {
writeProjectTextFile,
} from '../providers/registry';
import { useProjectFileEvents, type ProjectEvent } from '../providers/project-events';
import { composeSystemPrompt, type ResearchOptions } from '@open-design/contracts';
import {
composeSystemPrompt,
type MemorySystemPromptResponse,
type ResearchOptions,
} from '@open-design/contracts';
import { navigate } from '../router';
import { agentDisplayName, agentModelDisplayName } from '../utils/agentLabels';
import { isMacPlatform } from '../utils/platform';
@ -692,12 +696,31 @@ export function ProjectView({
}
}
}
// Fold in the auto-memory block so BYOK / API-mode chats see the
// same Personal-memory section a daemon-side CLI chat would. The
// daemon does this by calling `composeMemoryBody()` directly; the
// web side hits the equivalent HTTP surface so it can stay
// ignorant of daemon internals. Failures are swallowed — memory is
// best-effort, never a blocker for the chat round-trip.
let memoryBody: string | undefined;
try {
const resp = await fetch('/api/memory/system-prompt');
if (resp.ok) {
const json = (await resp.json()) as MemorySystemPromptResponse;
if (typeof json.body === 'string' && json.body.trim().length > 0) {
memoryBody = json.body;
}
}
} catch {
// Ignore; memory injection is best-effort.
}
return composeSystemPrompt({
skillBody,
skillName,
skillMode,
designSystemBody,
designSystemTitle,
memoryBody,
metadata: project.metadata,
template,
streamFormat: config.mode === 'api' ? 'plain' : undefined,
@ -1365,15 +1388,85 @@ export function ProjectView({
},
});
} else {
// Mirror the daemon chat-route memory hook for BYOK chats. The
// CLI path runs `extractFromMessage` BEFORE composing the prompt
// (so an explicit "remember: X" / "我是 X" marker in this turn's
// user message lands in memory in time for this turn's system
// prompt), then queues `extractWithLLM` on child close (so the
// small-model pass picks up implicit facts from the full
// user+assistant exchange). BYOK chats never hit that route, so
// we replicate both phases here against `/api/memory/extract`.
// Without this, the Memory tab / model picker is a no-op for
// BYOK users even though the UI saves model + index + entries
// for that mode.
const userText = (userMsg.content ?? '').trim();
// Snapshot the live BYOK chat config so the daemon can run
// "Same as chat" memory extraction against the same vendor /
// key / baseUrl / apiVersion the user is chatting with. The
// daemon never persists BYOK creds itself, so this per-call
// signal is the only way `pickProvider()` can avoid falling
// through to env / media-config (which is wrong for BYOK)
// when no explicit memory model override is set. The picker
// re-syncs an *explicit* override when chat config drifts;
// this snapshot covers the implicit "Same as chat" default.
const byokChatProvider =
config.apiProtocol && config.apiKey
? {
provider: config.apiProtocol,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
apiVersion:
config.apiProtocol === 'azure'
? config.apiVersion ?? ''
: '',
}
: undefined;
if (userText.length > 0) {
try {
await fetch('/api/memory/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userMessage: userText,
projectId: project.id,
conversationId: activeConversationId,
chatProvider: byokChatProvider,
}),
});
} catch {
// Best-effort: memory extraction must never block the
// chat. The daemon's SSE bus will catch up the Memory tab
// on the next event.
}
}
const systemPrompt = await composedSystemPrompt();
const apiHistory = historyWithCommentAttachmentContext(nextHistory, userMsg.id);
pushEvent({ kind: 'status', label: 'requesting', detail: config.model });
let accumulatedAssistantText = '';
void streamMessage(config, systemPrompt, apiHistory, controller.signal, {
onDelta: (delta) => {
accumulatedAssistantText += delta;
handlers.onDelta(delta);
handlers.onAgentEvent({ kind: 'text', text: delta });
},
onDone: handlers.onDone,
onDone: () => {
handlers.onDone();
const assistantText = accumulatedAssistantText.trim();
if (userText.length === 0 || assistantText.length === 0) return;
void fetch('/api/memory/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userMessage: userText,
assistantMessage: accumulatedAssistantText,
projectId: project.id,
conversationId: activeConversationId,
chatProvider: byokChatProvider,
}),
}).catch(() => {
// Best-effort: see comment above on the pre-turn call.
});
},
onError: handlers.onError,
});
}

View file

@ -24,6 +24,12 @@ import {
} from '../state/config';
import type { KnownProvider } from '../state/config';
import { navigate as navigateRoute } from '../router';
import {
API_KEY_PLACEHOLDERS,
API_PROTOCOL_LABELS,
API_PROTOCOL_TABS,
SUGGESTED_MODELS_BY_PROTOCOL,
} from '../state/apiProtocols';
import {
MAX_MAX_TOKENS,
MIN_MAX_TOKENS,
@ -55,6 +61,8 @@ import { LibrarySection } from './LibrarySection';
import { PrivacySection } from './PrivacySection';
import { RoutinesSection } from './RoutinesSection';
import { ConnectorsBrowser } from './ConnectorsBrowser';
import { MemoryModelInline } from './MemoryModelInline';
import { MemorySection } from './MemorySection';
import {
applyAppearanceToDocument,
normalizeAccentColor,
@ -80,6 +88,7 @@ export type SettingsSection =
| 'appearance'
| 'notifications'
| 'pet'
| 'memory'
| 'library'
| 'privacy'
| 'about';
@ -131,114 +140,6 @@ export interface AgentRefreshOptions {
agentCliEnv?: AppConfig['agentCliEnv'];
}
const SUGGESTED_MODELS_BY_PROTOCOL = {
anthropic: [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.7',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.1-highspeed',
'MiniMax-M2.1',
'MiniMax-M2',
'mimo-v2.5-pro',
],
openai: [
'gpt-4o',
'gpt-4o-mini',
'o3',
'o4-mini',
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.7',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.1-highspeed',
'MiniMax-M2.1',
'MiniMax-M2',
'mimo-v2.5-pro',
],
ollama: [
'cogito-2.1:671b',
'deepseek-v3.1:671b',
'deepseek-v3.2',
'deepseek-v4-flash',
'deepseek-v4-pro',
'devstral-2:123b',
'devstral-small-2:24b',
'gemini-3-flash-preview',
'gemma3:4b',
'gemma3:12b',
'gemma3:27b',
'gemma4:31b',
'glm-4.6',
'glm-4.7',
'glm-5',
'glm-5.1',
'gpt-oss:20b',
'gpt-oss:120b',
'kimi-k2:1t',
'kimi-k2-thinking',
'kimi-k2.5',
'kimi-k2.6',
'minimax-m2',
'minimax-m2.1',
'minimax-m2.5',
'minimax-m2.7',
'ministral-3:3b',
'ministral-3:8b',
'ministral-3:14b',
'mistral-large-3:675b',
'nemotron-3-nano:30b',
'nemotron-3-super',
'qwen3-coder:480b',
'qwen3-coder-next',
'qwen3-next:80b',
'qwen3-vl:235b',
'qwen3-vl:235b-instruct',
'qwen3.5:397b',
'rnj-1:8b',
],
azure: [
'gpt-4o',
'gpt-4o-mini',
],
google: [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-1.5-pro',
'gemini-1.5-flash',
],
} as const;
const API_PROTOCOL_TABS: Array<{
id: ApiProtocol;
title: string;
}> = [
{ id: 'anthropic', title: 'Anthropic' },
{ id: 'openai', title: 'OpenAI' },
{ id: 'azure', title: 'Azure OpenAI' },
{ id: 'google', title: 'Google Gemini' },
{ id: 'ollama', title: 'Ollama Cloud' },
];
const API_PROTOCOL_LABELS: Record<ApiProtocol, string> = {
anthropic: 'Anthropic API',
openai: 'OpenAI API',
azure: 'Azure OpenAI',
google: 'Google Gemini',
ollama: 'Ollama Cloud API',
};
function codexPathStrings(locale: Locale) {
if (locale === 'zh-CN') {
return {
@ -287,14 +188,6 @@ function sanitizeHttpsUrl(url: string | undefined): string | undefined {
}
}
const API_KEY_PLACEHOLDERS: Record<ApiProtocol, string> = {
anthropic: 'sk-ant-...',
openai: 'sk-...',
azure: 'azure key',
google: 'AIza...',
ollama: 'Ollama API key',
};
type RescanNotice =
| { kind: 'success'; count: number }
| { kind: 'error' };
@ -1411,6 +1304,7 @@ export function SettingsDialog({
notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') },
privacy: { title: t('settings.privacy'), subtitle: t('settings.privacyHint') },
pet: { title: t('pet.title'), subtitle: t('pet.subtitle') },
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
library: { title: t('settings.library'), subtitle: t('settings.libraryHint') },
about: { title: t('settings.about'), subtitle: t('settings.aboutHint') },
};
@ -1522,6 +1416,28 @@ export function SettingsDialog({
<small>{`${t('settings.localCli')} / ${t('settings.modeApiMeta')}`}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'memory' ? ' active' : ''}`}
onClick={() => setActiveSection('memory')}
>
<Icon name="history" size={18} />
<span>
<strong>{t('settings.memory')}</strong>
<small>{t('settings.memoryHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'library' ? ' active' : ''}`}
onClick={() => setActiveSection('library')}
>
<Icon name="grid" size={18} />
<span>
<strong>{t('settings.library')}</strong>
<small>{t('settings.libraryHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'media' ? ' active' : ''}`}
@ -1632,17 +1548,6 @@ export function SettingsDialog({
<small>{t('pet.navHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'library' ? ' active' : ''}`}
onClick={() => setActiveSection('library')}
>
<Icon name="grid" size={18} />
<span>
<strong>{t('settings.library')}</strong>
<small>{t('settings.libraryHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
@ -2077,6 +1982,20 @@ export function SettingsDialog({
</select>
</label>
) : null}
<MemoryModelInline
mode="daemon"
apiProtocol={apiProtocol}
chatApiKey={cfg.apiKey}
chatBaseUrl={cfg.baseUrl}
chatApiVersion={cfg.apiVersion ?? ''}
chatModel={modelValue}
cliAgentId={selected.id}
cliModelOptions={
hasModels
? selected.models!.map((m) => m.id)
: []
}
/>
<p className="hint">{t('settings.modelPickerHint')}</p>
</div>
);
@ -2321,6 +2240,14 @@ export function SettingsDialog({
/>
</label>
) : null}
<MemoryModelInline
mode="api"
apiProtocol={apiProtocol}
chatApiKey={cfg.apiKey}
chatBaseUrl={cfg.baseUrl}
chatApiVersion={cfg.apiVersion ?? ''}
chatModel={cfg.model}
/>
<label className="field">
<span className="field-label">{t('settings.baseUrl')}</span>
<input
@ -2498,6 +2425,8 @@ export function SettingsDialog({
<PetSettings cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'memory' ? <MemorySection /> : null}
{activeSection === 'library' ? (
<LibrarySection cfg={cfg} setCfg={setCfg} />
) : null}

View file

@ -1157,7 +1157,82 @@ export const ar: Dict = {
'settings.autosaveSaving': "جارٍ الحفظ…",
'settings.autosaveSaved': "تم حفظ جميع التغييرات",
'settings.autosaveError': "تعذّر حفظ التغييرات. قد يكون الـ daemon المحلي غير متاح.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'تبديل',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'عرض',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'تثبيت',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'مسار محلي',
@ -1171,6 +1246,15 @@ export const ar: Dict = {
'notify.failureTitle': 'فشلت المهمة',
'notify.successBody': 'انتهت جولة.',
'notify.failureBody': 'انتهت المهمة بخطأ.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'الأتمتة',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'ملخص يومي للموصّلات',

View file

@ -1045,7 +1045,82 @@ export const de: Dict = {
'settings.autosaveSaving': "Speichere…",
'settings.autosaveSaved': "Alle Änderungen gespeichert",
'settings.autosaveError': "Änderungen konnten nicht gespeichert werden. Der lokale Daemon ist möglicherweise offline.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Umschalten',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Speicher',
'settings.memoryHint': 'Persönliche Fakten, automatisch aus Chats extrahiert',
'settings.memoryDescription': 'Aus Chats extrahierte Fakten zu deinen Präferenzen, gespeichert als Markdown-Dateien und in jeden Chat eingespielt.',
'settings.memoryEnabled': 'Aktiviert',
'settings.memoryDisabled': 'Deaktiviert',
'settings.memoryEnableLabel': 'Speicher-Injektion aktivieren',
'settings.memoryDisabledBanner': 'Der Speicher ist derzeit AUS. Bestehende Fakten bleiben auf der Festplatte, werden aber nicht in neue Chats eingespielt.',
'settings.memoryNew': 'Neuer Eintrag',
'settings.memoryEdit': 'Bearbeiten',
'settings.memoryDelete': 'Löschen',
'settings.memoryPreview': 'Vorschau',
'settings.memoryEmpty': 'Noch keine Erinnerungen.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'Einzeilige Beschreibung',
'settings.memoryBody': 'Inhalt (Markdown unterstützt)',
'settings.memoryBodyHint': 'Regel zuerst, dann Zeilen Why und How to apply.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'Benutzer',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Projekt',
'settings.memoryTypeReference': 'Referenz',
'settings.memoryIndex': 'MEMORY.md (Index)',
'settings.memoryIndexSave': 'Index speichern',
'settings.memoryIndexReset': 'Zurücksetzen',
'settings.memoryToastChanged': 'Speicher aktualisiert',
'settings.memoryToastClickHint': 'Anzeigen',
'settings.memoryAll': 'Alle',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Installieren',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Lokaler Pfad',
@ -1059,6 +1134,15 @@ export const de: Dict = {
'notify.failureTitle': 'Aufgabe fehlgeschlagen',
'notify.successBody': 'Eine Runde ist abgeschlossen.',
'notify.failureBody': 'Die Aufgabe wurde mit einem Fehler beendet.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automatisierung',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Tägliche Connector-Zusammenfassung',

View file

@ -1260,6 +1260,81 @@ export const en: Dict = {
'settings.autosaveSaved': 'All changes saved',
'settings.autosaveError': 'Couldn\u2019t save changes. The local daemon may be offline.',
'settings.libraryToggleLabel': 'Toggle',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'e.g. UI preferences',
'settings.memoryDesc': 'One sentence — what is this memory about?',
'settings.memoryBody': '- Rule one\n- Rule two\n\nWhy: optional reason\nWhen to apply: optional scope',
'settings.memoryBodyHint': 'Bullet the rules first, then optional Why and When-to-apply lines. Markdown supported. Or click a starter above to load a working example.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'View',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent extraction attempts. Heuristic regex extraction runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.libraryInstall': 'Install',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Local Path',
@ -1335,4 +1410,13 @@ export const en: Dict = {
'notify.failureTitle': 'Task failed',
'notify.successBody': 'A turn has finished.',
'notify.failureBody': 'The task ended with an error.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
};

View file

@ -1046,7 +1046,82 @@ export const esES: Dict = {
'settings.autosaveSaving': "Guardando…",
'settings.autosaveSaved': "Todos los cambios guardados",
'settings.autosaveError': "No se pudieron guardar los cambios. Es posible que el daemon local esté desconectado.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Alternar',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memoria',
'settings.memoryHint': 'Datos personales extraídos automáticamente de los chats',
'settings.memoryDescription': 'Datos sobre tus preferencias extraídos automáticamente, guardados como Markdown e inyectados en cada chat.',
'settings.memoryEnabled': 'Activada',
'settings.memoryDisabled': 'Desactivada',
'settings.memoryEnableLabel': 'Activar inyección de memoria',
'settings.memoryDisabledBanner': 'La memoria está desactivada. Los datos existentes permanecen en disco pero no se inyectarán en nuevos chats.',
'settings.memoryNew': 'Nueva memoria',
'settings.memoryEdit': 'Editar',
'settings.memoryDelete': 'Eliminar',
'settings.memoryPreview': 'Vista previa',
'settings.memoryEmpty': 'Aún no hay memoria.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Nombre',
'settings.memoryDesc': 'Descripción en una línea',
'settings.memoryBody': 'Cuerpo (Markdown compatible)',
'settings.memoryBodyHint': 'La regla primero, luego Why y How to apply.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'Usuario',
'settings.memoryTypeFeedback': 'Retroalimentación',
'settings.memoryTypeProject': 'Proyecto',
'settings.memoryTypeReference': 'Referencia',
'settings.memoryIndex': 'MEMORY.md (índice)',
'settings.memoryIndexSave': 'Guardar índice',
'settings.memoryIndexReset': 'Restablecer',
'settings.memoryToastChanged': 'Memoria actualizada',
'settings.memoryToastClickHint': 'Ver',
'settings.memoryAll': 'Todo',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Instalar',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Ruta local',
@ -1060,6 +1135,15 @@ export const esES: Dict = {
'notify.failureTitle': 'La tarea falló',
'notify.successBody': 'Un turno ha terminado.',
'notify.failureBody': 'La tarea terminó con un error.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automatización',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Resumen diario de conectores',

View file

@ -1191,7 +1191,82 @@ export const fa: Dict = {
'settings.autosaveSaving': "در حال ذخیره…",
'settings.autosaveSaved': "همهٔ تغییرات ذخیره شد",
'settings.autosaveError': "تغییرات ذخیره نشد. ممکن است daemon محلی آفلاین باشد.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'تغییر وضعیت',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'مشاهده',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'نصب',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'مسیر محلی',
@ -1205,6 +1280,15 @@ export const fa: Dict = {
'notify.failureTitle': 'وظیفه ناموفق بود',
'notify.successBody': 'یک نوبت به پایان رسید.',
'notify.failureBody': 'وظیفه با خطا پایان یافت.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'اتوماسیون',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'خلاصهٔ روزانهٔ کانکتورها',

View file

@ -1157,7 +1157,82 @@ export const fr: Dict = {
'settings.autosaveSaving': "Enregistrement…",
'settings.autosaveSaved': "Toutes les modifications enregistrées",
'settings.autosaveError': "Impossible denregistrer les modifications. Le daemon local est peut-être hors ligne.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Basculer',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Mémoire',
'settings.memoryHint': 'Faits personnels extraits automatiquement des conversations',
'settings.memoryDescription': 'Faits sur vos préférences extraits automatiquement, sauvegardés en Markdown et réinjectés dans chaque chat.',
'settings.memoryEnabled': 'Activée',
'settings.memoryDisabled': 'Désactivée',
'settings.memoryEnableLabel': 'Activer l\'injection de mémoire',
'settings.memoryDisabledBanner': 'La mémoire est actuellement désactivée. Les faits existants restent sur le disque mais ne seront pas injectés dans les nouveaux chats.',
'settings.memoryNew': 'Nouvelle mémoire',
'settings.memoryEdit': 'Modifier',
'settings.memoryDelete': 'Supprimer',
'settings.memoryPreview': 'Aperçu',
'settings.memoryEmpty': 'Aucune mémoire pour l\'instant.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Nom',
'settings.memoryDesc': 'Description en une ligne',
'settings.memoryBody': 'Corps (Markdown pris en charge)',
'settings.memoryBodyHint': 'Énoncez la règle, puis ajoutez Why et How to apply.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'Utilisateur',
'settings.memoryTypeFeedback': 'Retour',
'settings.memoryTypeProject': 'Projet',
'settings.memoryTypeReference': 'Référence',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Enregistrer l\'index',
'settings.memoryIndexReset': 'Réinitialiser',
'settings.memoryToastChanged': 'Mémoire mise à jour',
'settings.memoryToastClickHint': 'Afficher',
'settings.memoryAll': 'Tout',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Installer',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Chemin local',
@ -1171,6 +1246,15 @@ export const fr: Dict = {
'notify.failureTitle': 'Tâche échouée',
'notify.successBody': 'Un tour est terminé.',
'notify.failureBody': 'La tâche s\'est terminée avec une erreur.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automatisation',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Résumé quotidien des connecteurs',

View file

@ -1167,7 +1167,82 @@ export const hu: Dict = {
'settings.autosaveSaving': "Mentés…",
'settings.autosaveSaved': "Minden változtatás mentve",
'settings.autosaveError': "A változtatások mentése nem sikerült. A helyi daemon offline lehet.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Átváltás',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Megnyitás',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Telepítés',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Helyi elérési út',
@ -1181,6 +1256,15 @@ export const hu: Dict = {
'notify.failureTitle': 'A feladat meghiúsult',
'notify.successBody': 'Egy kör befejeződött.',
'notify.failureBody': 'A feladat hibával ért véget.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automatizálás',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Napi csatlakozó-összefoglaló',

View file

@ -1267,6 +1267,81 @@ export const id: Dict = {
'settings.libraryEnabled': 'Aktif',
'settings.libraryDisabled': 'Nonaktif',
'settings.libraryToggleLabel': 'Toggle',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Lihat',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Instal',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Jalur lokal',
@ -1281,4 +1356,13 @@ export const id: Dict = {
'notify.failureTitle': 'Pembuatan gagal',
'notify.successBody': 'Artifact siap dilihat.',
'notify.failureBody': 'Cek chat untuk detail error.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
};

View file

@ -1044,7 +1044,82 @@ export const ja: Dict = {
'settings.autosaveSaving': "保存中…",
'settings.autosaveSaved': "すべての変更を保存しました",
'settings.autosaveError': "変更を保存できませんでした。ローカル daemon がオフラインの可能性があります。",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': '切り替え',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'メモリ',
'settings.memoryHint': 'チャットから自動抽出された個人情報',
'settings.memoryDescription': 'あなたの好みや背景情報を自動的に抽出し、Markdown ファイルとして保存します。次回以降のチャットに自動で反映されます。',
'settings.memoryEnabled': '有効',
'settings.memoryDisabled': '無効',
'settings.memoryEnableLabel': 'メモリの挿入を有効にする',
'settings.memoryDisabledBanner': 'メモリは現在オフです。既存の事実はディスク上に残りますが、新しい会話には注入されず、新しい情報の抽出も行われません。',
'settings.memoryNew': '新規メモリ',
'settings.memoryEdit': '編集',
'settings.memoryDelete': '削除',
'settings.memoryPreview': 'プレビュー',
'settings.memoryEmpty': 'まだメモリはありません。',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': '名前',
'settings.memoryDesc': '1 行の説明',
'settings.memoryBody': 'メモリ本文Markdown 対応)',
'settings.memoryBodyHint': 'ルール本体を先に書き、Why と How to apply の 2 行を続けて。',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'ユーザー',
'settings.memoryTypeFeedback': 'フィードバック',
'settings.memoryTypeProject': 'プロジェクト',
'settings.memoryTypeReference': '参照',
'settings.memoryIndex': 'MEMORY.md索引',
'settings.memoryIndexSave': '索引を保存',
'settings.memoryIndexReset': 'リセット',
'settings.memoryToastChanged': 'メモリを更新しました',
'settings.memoryToastClickHint': '表示',
'settings.memoryAll': 'すべて',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'インストール',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'ローカルパス',
@ -1058,6 +1133,15 @@ export const ja: Dict = {
'notify.failureTitle': 'タスクが失敗しました',
'notify.successBody': '1ターンが終了しました。',
'notify.failureBody': 'タスクはエラーで終了しました。',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': '自動化',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': '日次コネクターサマリー',

View file

@ -1157,7 +1157,82 @@ export const ko: Dict = {
'settings.autosaveSaving': "저장 중…",
'settings.autosaveSaved': "모든 변경사항이 저장됨",
'settings.autosaveError': "변경사항을 저장하지 못했습니다. 로컬 데몬이 오프라인일 수 있습니다.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': '전환',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': '메모리',
'settings.memoryHint': '대화에서 자동으로 추출된 개인 정보',
'settings.memoryDescription': '취향과 컨텍스트에 관한 사실을 대화에서 자동으로 추출하여 Markdown 파일로 저장하고, 이후 대화에 자동 주입합니다.',
'settings.memoryEnabled': '활성화됨',
'settings.memoryDisabled': '비활성화됨',
'settings.memoryEnableLabel': '메모리 주입 활성화',
'settings.memoryDisabledBanner': '메모리가 현재 꺼져 있습니다. 기존 사실은 디스크에 유지되지만 새 대화에 주입되지 않으며 새로운 정보도 추출되지 않습니다.',
'settings.memoryNew': '새 메모리',
'settings.memoryEdit': '편집',
'settings.memoryDelete': '삭제',
'settings.memoryPreview': '미리보기',
'settings.memoryEmpty': '아직 메모리가 없습니다.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': '이름',
'settings.memoryDesc': '한 줄 설명',
'settings.memoryBody': '메모리 본문 (Markdown 지원)',
'settings.memoryBodyHint': '규칙 본문을 먼저 적고 Why와 How to apply 두 줄을 추가하세요.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': '사용자',
'settings.memoryTypeFeedback': '피드백',
'settings.memoryTypeProject': '프로젝트',
'settings.memoryTypeReference': '참조',
'settings.memoryIndex': 'MEMORY.md (색인)',
'settings.memoryIndexSave': '색인 저장',
'settings.memoryIndexReset': '초기화',
'settings.memoryToastChanged': '메모리가 업데이트되었습니다',
'settings.memoryToastClickHint': '보기',
'settings.memoryAll': '전체',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': '설치',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': '로컬 경로',
@ -1171,6 +1246,15 @@ export const ko: Dict = {
'notify.failureTitle': '작업 실패',
'notify.successBody': '한 턴이 끝났습니다.',
'notify.failureBody': '작업이 오류로 종료되었습니다.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': '자동화',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': '일일 커넥터 요약',

View file

@ -1157,7 +1157,82 @@ export const pl: Dict = {
'settings.autosaveSaving': "Zapisywanie…",
'settings.autosaveSaved': "Wszystkie zmiany zapisane",
'settings.autosaveError': "Nie udało się zapisać zmian. Lokalny demon może być offline.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Przełącz',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Zobacz',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Zainstaluj',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Ścieżka lokalna',
@ -1171,6 +1246,15 @@ export const pl: Dict = {
'notify.failureTitle': 'Zadanie nieudane',
'notify.successBody': 'Tura zakończona.',
'notify.failureBody': 'Zadanie zakończyło się błędem.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automatyzacja',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Dzienne podsumowanie konektorów',

View file

@ -1189,7 +1189,82 @@ export const ptBR: Dict = {
'settings.autosaveSaving': "Salvando…",
'settings.autosaveSaved': "Todas as alterações foram salvas",
'settings.autosaveError': "Não foi possível salvar as alterações. O daemon local pode estar offline.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Alternar',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Ver',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Instalar',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Caminho local',
@ -1203,6 +1278,15 @@ export const ptBR: Dict = {
'notify.failureTitle': 'Tarefa falhou',
'notify.successBody': 'Uma rodada foi concluída.',
'notify.failureBody': 'A tarefa terminou com erro.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Automação',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Resumo diário de conectores',

View file

@ -1189,7 +1189,82 @@ export const ru: Dict = {
'settings.autosaveSaving': "Сохранение…",
'settings.autosaveSaved': "Все изменения сохранены",
'settings.autosaveError': "Не удалось сохранить изменения. Возможно, локальный демон не в сети.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Переключить',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Память',
'settings.memoryHint': 'Личные факты, автоматически извлечённые из чатов',
'settings.memoryDescription': 'Факты о ваших предпочтениях, автоматически извлечённые из чатов, сохранены как Markdown-файлы и подмешиваются в каждый новый чат.',
'settings.memoryEnabled': 'Включено',
'settings.memoryDisabled': 'Выключено',
'settings.memoryEnableLabel': 'Включить вставку памяти',
'settings.memoryDisabledBanner': 'Память сейчас выключена. Существующие факты сохраняются на диске, но не подмешиваются в новые чаты.',
'settings.memoryNew': 'Новая запись',
'settings.memoryEdit': 'Изменить',
'settings.memoryDelete': 'Удалить',
'settings.memoryPreview': 'Просмотр',
'settings.memoryEmpty': 'Пока нет записей.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Название',
'settings.memoryDesc': 'Однострочное описание',
'settings.memoryBody': 'Содержимое (поддерживается Markdown)',
'settings.memoryBodyHint': 'Сначала правило, затем строки Why и How to apply.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'Пользователь',
'settings.memoryTypeFeedback': 'Отзыв',
'settings.memoryTypeProject': 'Проект',
'settings.memoryTypeReference': 'Ссылка',
'settings.memoryIndex': 'MEMORY.md (индекс)',
'settings.memoryIndexSave': 'Сохранить индекс',
'settings.memoryIndexReset': 'Сбросить',
'settings.memoryToastChanged': 'Память обновлена',
'settings.memoryToastClickHint': 'Открыть',
'settings.memoryAll': 'Все',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Установить',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Локальный путь',
@ -1203,6 +1278,15 @@ export const ru: Dict = {
'notify.failureTitle': 'Задача завершилась с ошибкой',
'notify.successBody': 'Ход завершён.',
'notify.failureBody': 'Задача завершилась с ошибкой.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Автоматизация',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Ежедневная сводка коннекторов',

View file

@ -1148,7 +1148,82 @@ export const tr: Dict = {
'settings.autosaveSaving': "Kaydediliyor…",
'settings.autosaveSaved': "Tüm değişiklikler kaydedildi",
'settings.autosaveError': "Değişiklikler kaydedilemedi. Yerel daemon çevrimdışı olabilir.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Değiştir',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Görüntüle',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Yükle',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Yerel yol',
@ -1162,6 +1237,15 @@ export const tr: Dict = {
'notify.failureTitle': 'Görev başarısız oldu',
'notify.successBody': 'Bir tur tamamlandı.',
'notify.failureBody': 'Görev bir hata ile sona erdi.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Otomasyon',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Günlük bağlayıcı özeti',

View file

@ -1190,7 +1190,82 @@ export const uk: Dict = {
'settings.autosaveSaving': "Збереження…",
'settings.autosaveSaved': "Усі зміни збережено",
'settings.autosaveError': "Не вдалося зберегти зміни. Можливо, локальний демон офлайн.",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': 'Перемкнути',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': 'Memory',
'settings.memoryHint': 'Personal facts auto-extracted from chats',
'settings.memoryDescription': 'Auto-extracted facts about you and your preferences. Saved as Markdown files and folded into every chat.',
'settings.memoryEnabled': 'Enabled',
'settings.memoryDisabled': 'Disabled',
'settings.memoryEnableLabel': 'Enable memory injection',
'settings.memoryDisabledBanner': 'Memory is currently OFF. Existing facts are preserved on disk but will not be folded into new chats and new turns will not extract anything new.',
'settings.memoryNew': 'New memory',
'settings.memoryEdit': 'Edit',
'settings.memoryDelete': 'Delete',
'settings.memoryPreview': 'Preview',
'settings.memoryEmpty': 'No memory yet.',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': 'Name',
'settings.memoryDesc': 'One-line description',
'settings.memoryBody': 'Memory body (Markdown supported)',
'settings.memoryBodyHint': 'Lead with the rule itself; add Why and How to apply lines.',
'settings.memoryStartersLabel': 'Need a starting point? Click to fill the form:',
'settings.memoryStarterUserName': 'My role',
'settings.memoryStarterUserDesc': 'I am a frontend engineer working on a SaaS design tool',
'settings.memoryStarterUserBody': '- Role: senior frontend engineer\n- Stack: React, TypeScript, Vite\n- Domain: design / collaboration tools\n- Timezone: GMT+8 (Asia/Shanghai)\n\nWhen to apply: any chat — frame examples around web frontend.',
'settings.memoryStarterFeedbackName': 'UI preferences',
'settings.memoryStarterFeedbackDesc': 'Dark mode, large body text, low information density',
'settings.memoryStarterFeedbackBody': '- Theme: dark by default\n- Body text: ≥ 18px\n- Information density: prefer whitespace, fewer items per screen\n\nWhy: less eye strain during long sessions.\nWhen to apply: whenever you generate UI, web pages, or slides.',
'settings.memoryStarterProjectName': 'Current project',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — chat-driven design editor',
'settings.memoryStarterProjectBody': '- Goal: ship the chat-driven editor this quarter\n- Priorities: streaming render, local multimodal, offline-first\n- Stack: Next.js 16, Express daemon, SQLite\n\nWhen to apply: in any conversation about this project.',
'settings.memorySaveHint': 'Not auto-saved — click Create / Save to apply.',
'settings.memoryIndexSaveHint': 'Edits to the index are not auto-saved — click Save index to apply.',
'settings.memoryIndexUnsaved': 'Unsaved changes',
'settings.memoryFlashCreated': '✓ Memory created',
'settings.memoryFlashSaved': '✓ Memory saved',
'settings.memoryFlashDeleted': '✓ Memory deleted',
'settings.memoryFlashIndexSaved': '✓ Index saved',
'settings.memoryNameLabel': 'Title',
'settings.memoryTypeLabel': 'Type',
'settings.memoryDescLabel': 'Description',
'settings.memoryBodyLabel': 'Content',
'settings.memoryTypeUser': 'User',
'settings.memoryTypeFeedback': 'Feedback',
'settings.memoryTypeProject': 'Project',
'settings.memoryTypeReference': 'Reference',
'settings.memoryIndex': 'MEMORY.md (index)',
'settings.memoryIndexSave': 'Save index',
'settings.memoryIndexReset': 'Reset',
'settings.memoryToastChanged': 'Memory updated',
'settings.memoryToastClickHint': 'Переглянути',
'settings.memoryAll': 'All',
'settings.memoryExtractions': 'Extraction history',
'settings.memoryExtractionsHint': 'Recent LLM-backed extraction attempts. Heuristic regex extraction always runs first; LLM extraction runs in the background after each turn.',
'settings.memoryExtractionsEmpty': 'No extractions yet. The next chat turn will populate this list.',
'settings.memoryExtractionsRefresh': 'Refresh',
'settings.memoryExtractionPhaseRunning': 'Running…',
'settings.memoryExtractionPhaseSuccess': 'Success',
'settings.memoryExtractionPhaseSkipped': 'Skipped',
'settings.memoryExtractionPhaseFailed': 'Failed',
'settings.memoryExtractionSkipNoProvider': 'No API key configured for LLM memory extraction.',
'settings.memoryExtractionSkipDisabled': 'Memory is disabled.',
'settings.memoryExtractionSkipEmpty': 'Empty user message — nothing to extract.',
'settings.memoryExtractionSkipNoMatch': 'No regex pattern matched this turn.',
'settings.memoryExtractionKindHeuristic': 'regex',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': 'env',
'settings.memoryExtractionProviderMediaConfig': 'media settings',
'settings.memoryExtractionProposed': 'proposed',
'settings.memoryExtractionWritten': 'written',
'settings.memoryExtractionDuration': 'in',
'settings.memoryNoProviderBannerTitle': 'LLM memory extraction is not running',
'settings.memoryNoProviderBannerBody': 'No API key found for the memory extractor. Add an OpenAI key under Media providers, or set ANTHROPIC_API_KEY / OPENAI_API_KEY in the environment, to enable LLM-driven extraction. Heuristic regex extraction is still active.',
'settings.memoryExtractionProviderOverride': 'memory settings',
'settings.memoryExtractionDelete': 'Delete',
'settings.memoryExtractionsClear': 'Clear',
'settings.memoryExtractionsClearTitle': 'Clear all extraction history',
'settings.libraryInstall': 'Встановити',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': 'Локальний шлях',
@ -1204,6 +1279,15 @@ export const uk: Dict = {
'notify.failureTitle': 'Завдання не вдалося',
'notify.successBody': 'Черга завершена.',
'notify.failureBody': 'Завдання завершилось помилкою.',
'settings.memoryModelInlineLabel': 'Memory model',
'settings.memoryModelInlineSameAsChat': 'Same as chat',
'settings.memoryModelInlineSameAsChatWithModel': 'Same as chat ({model})',
'settings.memoryModelInlineSameAsChatWithProvider': 'Same as chat ({provider})',
'settings.memoryModelInlineHintCli': 'Optional. The memory extractor uses an env-var or media-providers API key on this provider; pinning a model here just overrides the auto-pick.',
'settings.memoryModelInlineHintCliConstrained': 'Optional. Memory will call {provider}; needs an env-var or media-providers API key for that provider, or pick a model below to override.',
'settings.memoryModelInlineHintByok': 'Optional. Reuses your chat API key on the same provider — picking a different (usually cheaper) model only changes the request body.',
'settings.memoryModelInlineFlashSaved': 'Saved',
'settings.memoryModelInlineFlashCleared': 'Cleared',
'settings.orbit.eyebrow': 'Автоматизація',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': 'Щоденний підсумок конекторів',

View file

@ -1229,7 +1229,82 @@ export const zhCN: Dict = {
'settings.autosaveSaving': "保存中…",
'settings.autosaveSaved': "所有更改已保存",
'settings.autosaveError': "保存更改失败。本地 daemon 可能不在线。",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': '切换',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': '记忆',
'settings.memoryHint': '从对话中自动沉淀的个性化信息',
'settings.memoryDescription': '自动从聊天中提取出的关于你的偏好和上下文的事实,以 Markdown 文件形式保存,并自动注入到每次对话中。',
'settings.memoryEnabled': '已启用',
'settings.memoryDisabled': '已关闭',
'settings.memoryEnableLabel': '启用记忆注入',
'settings.memoryDisabledBanner': '记忆当前已关闭。已有的事实文件保留在磁盘上,但不会被注入到新对话,也不会从新对话中提取新事实。',
'settings.memoryNew': '新建记忆',
'settings.memoryEdit': '编辑',
'settings.memoryDelete': '删除',
'settings.memoryPreview': '预览',
'settings.memoryEmpty': '还没有记忆。',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': '例如UI 偏好',
'settings.memoryDesc': '一句话 — 这条记忆是关于什么的?',
'settings.memoryBody': '- 规则一\n- 规则二\n\n为什么可选\n何时适用可选',
'settings.memoryBodyHint': '先用 bullet 列出规则,再补「为什么」和「何时适用」(可选)。支持 Markdown。也可以点上方示例直接载入。',
'settings.memoryStartersLabel': '不知道写什么?点一下填到表单里:',
'settings.memoryStarterUserName': '我的角色',
'settings.memoryStarterUserDesc': '我是一名前端工程师,在做 SaaS 设计工具',
'settings.memoryStarterUserBody': '- 角色:高级前端工程师\n- 技术栈React、TypeScript、Vite\n- 方向:设计 / 协作工具\n- 时区GMT+8Asia/Shanghai\n\n何时适用任何对话里举例时优先用前端 / Web 场景。',
'settings.memoryStarterFeedbackName': 'UI 偏好',
'settings.memoryStarterFeedbackDesc': '深色主题、字号偏大、信息密度低',
'settings.memoryStarterFeedbackBody': '- 主题:默认深色\n- 正文字号:≥ 18px\n- 信息密度:留白多一些,一屏不要塞太多东西\n\n为什么长时间使用眼睛不容易累。\n何时适用让你画 UI、网页、PPT 时都按这个走。',
'settings.memoryStarterProjectName': '当前项目',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — 聊天驱动的设计编辑器',
'settings.memoryStarterProjectBody': '- 目标:本季度交付聊天驱动的编辑体验\n- 优先级:流式渲染、本地多模态、离线优先\n- 技术栈Next.js 16、Express daemon、SQLite\n\n何时适用与本项目相关的所有对话。',
'settings.memorySaveHint': '不会自动保存 — 点击「创建」/「保存」才生效。',
'settings.memoryIndexSaveHint': '索引不会自动保存 — 改完后点击「保存索引」才生效。',
'settings.memoryIndexUnsaved': '有未保存的修改',
'settings.memoryFlashCreated': '✓ 已创建',
'settings.memoryFlashSaved': '✓ 已保存',
'settings.memoryFlashDeleted': '✓ 已删除',
'settings.memoryFlashIndexSaved': '✓ 索引已保存',
'settings.memoryNameLabel': '标题',
'settings.memoryTypeLabel': '类型',
'settings.memoryDescLabel': '描述',
'settings.memoryBodyLabel': '内容',
'settings.memoryTypeUser': '用户',
'settings.memoryTypeFeedback': '反馈',
'settings.memoryTypeProject': '项目',
'settings.memoryTypeReference': '引用',
'settings.memoryIndex': 'MEMORY.md索引',
'settings.memoryIndexSave': '保存索引',
'settings.memoryIndexReset': '重置',
'settings.memoryToastChanged': '记忆已更新',
'settings.memoryToastClickHint': '查看',
'settings.memoryAll': '全部',
'settings.memoryExtractions': '抽取历史',
'settings.memoryExtractionsHint': '最近的 LLM 抽取记录。每次对话结束后启发式正则会先跑LLM 抽取在后台异步进行。',
'settings.memoryExtractionsEmpty': '暂无抽取记录。下一次对话结束后会出现在这里。',
'settings.memoryExtractionsRefresh': '刷新',
'settings.memoryExtractionPhaseRunning': '抽取中…',
'settings.memoryExtractionPhaseSuccess': '成功',
'settings.memoryExtractionPhaseSkipped': '已跳过',
'settings.memoryExtractionPhaseFailed': '失败',
'settings.memoryExtractionSkipNoProvider': '未配置 API keyLLM 抽取未运行。',
'settings.memoryExtractionSkipDisabled': '记忆功能已关闭。',
'settings.memoryExtractionSkipEmpty': '用户消息为空,没有可抽取的内容。',
'settings.memoryExtractionSkipNoMatch': '本轮没有命中任何正则规则。',
'settings.memoryExtractionKindHeuristic': '正则',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': '环境变量',
'settings.memoryExtractionProviderMediaConfig': '媒体设置',
'settings.memoryExtractionProviderOverride': '记忆设置',
'settings.memoryExtractionProposed': '候选',
'settings.memoryExtractionWritten': '写入',
'settings.memoryExtractionDuration': '耗时',
'settings.memoryExtractionDelete': '删除',
'settings.memoryExtractionsClear': '清空',
'settings.memoryExtractionsClearTitle': '清空整个抽取历史',
'settings.memoryNoProviderBannerTitle': 'LLM 抽取未启用',
'settings.memoryNoProviderBannerBody': '未找到可用的 API keyLLM 抽取已跳过。可以在媒体提供商里填入 OpenAI key或设置环境变量 ANTHROPIC_API_KEY / OPENAI_API_KEY 来启用。启发式抽取仍在运行。',
'settings.libraryInstall': '安装',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': '本地路径',
@ -1243,6 +1318,15 @@ export const zhCN: Dict = {
'notify.failureTitle': '任务失败',
'notify.successBody': '一轮回答已经写完。',
'notify.failureBody': '本轮任务出错,请查看错误信息。',
'settings.memoryModelInlineLabel': 'Memory 模型',
'settings.memoryModelInlineSameAsChat': '与聊天一致',
'settings.memoryModelInlineSameAsChatWithModel': '与聊天一致({model}',
'settings.memoryModelInlineSameAsChatWithProvider': '与聊天一致({provider}',
'settings.memoryModelInlineHintCli': '可选。Memory 提取仍然使用环境变量或媒体设置里的 API key 调用对应供应商;这里选模型只是替换自动挑选的默认值。',
'settings.memoryModelInlineHintCliConstrained': '可选。Memory 将调用 {provider};需要对应的环境变量或媒体设置里的 API key或在下面选一个模型来覆盖。',
'settings.memoryModelInlineHintByok': '可选。复用你聊天用的 API key在同供应商上换一个通常更便宜的模型来跑后台 memory 提取。',
'settings.memoryModelInlineFlashSaved': '已保存',
'settings.memoryModelInlineFlashCleared': '已清除',
'settings.orbit.eyebrow': '自动化',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': '每日连接器摘要',

View file

@ -1222,7 +1222,82 @@ export const zhTW: Dict = {
'settings.autosaveSaving': "儲存中…",
'settings.autosaveSaved': "所有變更已儲存",
'settings.autosaveError': "無法儲存變更。本機 daemon 可能離線。",
'settings.libraryToggleLabel': 'Toggle',
'settings.libraryToggleLabel': '切換',
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': '記憶',
'settings.memoryHint': '從對話中自動沉澱的個人化資訊',
'settings.memoryDescription': '自動從聊天中提取出的關於你的偏好和上下文的事實,以 Markdown 檔案形式保存,並自動注入到每次對話中。',
'settings.memoryEnabled': '已啟用',
'settings.memoryDisabled': '已關閉',
'settings.memoryEnableLabel': '啟用記憶注入',
'settings.memoryDisabledBanner': '記憶目前已關閉。已有的事實檔案保留在磁碟上,但不會被注入到新對話,也不會從新對話中提取新事實。',
'settings.memoryNew': '新增記憶',
'settings.memoryEdit': '編輯',
'settings.memoryDelete': '刪除',
'settings.memoryPreview': '預覽',
'settings.memoryEmpty': '還沒有記憶。',
'settings.memoryEmptyHintZh': '记住: 用户偏好深色主题',
'settings.memoryEmptyHintEn': 'I prefer dark mode',
'settings.memoryName': '名稱',
'settings.memoryDesc': '一行描述',
'settings.memoryBody': '記憶正文(支援 Markdown',
'settings.memoryBodyHint': '先寫規則本身,再補「為什麼」和「何時適用」兩行。',
'settings.memoryStartersLabel': '不知道寫什麼?點一下填到表單裡:',
'settings.memoryStarterUserName': '我的角色',
'settings.memoryStarterUserDesc': '我是一名前端工程師,主要做 SaaS 設計工具',
'settings.memoryStarterUserBody': '- 角色:高級前端工程師\n- 技術棧React、TypeScript、Vite\n- 方向:設計 / 協作工具\n- 時區GMT+8Asia/Shanghai\n\n何時適用任何對話裡舉例時優先用前端 / Web 場景。',
'settings.memoryStarterFeedbackName': 'UI 偏好',
'settings.memoryStarterFeedbackDesc': '深色主題、字號偏大、資訊密度低',
'settings.memoryStarterFeedbackBody': '- 主題:預設深色\n- 正文字號:≥ 18px\n- 資訊密度:留白多一些,一屏不要塞太多東西\n\n為什麼長時間使用眼睛不容易累。\n何時適用讓你畫 UI、網頁、PPT 時都按這個走。',
'settings.memoryStarterProjectName': '當前專案',
'settings.memoryStarterProjectDesc': 'Open Design v0.5 — 聊天驅動的設計編輯器',
'settings.memoryStarterProjectBody': '- 目標:本季交付聊天驅動的編輯體驗\n- 優先級:串流渲染、本地多模態、離線優先\n- 技術棧Next.js 16、Express daemon、SQLite\n\n何時適用與本專案相關的所有對話。',
'settings.memorySaveHint': '不會自動儲存 — 點擊「建立」/「儲存」才會生效。',
'settings.memoryIndexSaveHint': '索引不會自動儲存 — 改完後點擊「儲存索引」才會生效。',
'settings.memoryIndexUnsaved': '有尚未儲存的修改',
'settings.memoryFlashCreated': '✓ 已建立',
'settings.memoryFlashSaved': '✓ 已儲存',
'settings.memoryFlashDeleted': '✓ 已刪除',
'settings.memoryFlashIndexSaved': '✓ 索引已儲存',
'settings.memoryNameLabel': '標題',
'settings.memoryTypeLabel': '類型',
'settings.memoryDescLabel': '描述',
'settings.memoryBodyLabel': '內容',
'settings.memoryTypeUser': '使用者',
'settings.memoryTypeFeedback': '回饋',
'settings.memoryTypeProject': '專案',
'settings.memoryTypeReference': '參考',
'settings.memoryIndex': 'MEMORY.md索引',
'settings.memoryIndexSave': '儲存索引',
'settings.memoryIndexReset': '重設',
'settings.memoryToastChanged': '記憶已更新',
'settings.memoryToastClickHint': '查看',
'settings.memoryAll': '全部',
'settings.memoryExtractions': '抽取歷史',
'settings.memoryExtractionsHint': '最近的 LLM 抽取記錄。每次對話結束後啟發式正則會先跑LLM 抽取在背景非同步進行。',
'settings.memoryExtractionsEmpty': '暫無抽取記錄。下一次對話結束後會出現在這裡。',
'settings.memoryExtractionsRefresh': '重新整理',
'settings.memoryExtractionPhaseRunning': '抽取中…',
'settings.memoryExtractionPhaseSuccess': '成功',
'settings.memoryExtractionPhaseSkipped': '已跳過',
'settings.memoryExtractionPhaseFailed': '失敗',
'settings.memoryExtractionSkipNoProvider': '未設定 API keyLLM 抽取未執行。',
'settings.memoryExtractionSkipDisabled': '記憶功能已關閉。',
'settings.memoryExtractionSkipEmpty': '使用者訊息為空,沒有可抽取的內容。',
'settings.memoryExtractionSkipNoMatch': '本輪沒有命中任何正則規則。',
'settings.memoryExtractionKindHeuristic': '正則',
'settings.memoryExtractionKindLlm': 'LLM',
'settings.memoryExtractionProviderEnv': '環境變數',
'settings.memoryExtractionProviderMediaConfig': '媒體設定',
'settings.memoryExtractionProposed': '候選',
'settings.memoryExtractionWritten': '寫入',
'settings.memoryExtractionDuration': '耗時',
'settings.memoryNoProviderBannerTitle': 'LLM 抽取未啟用',
'settings.memoryNoProviderBannerBody': '未找到可用的 API keyLLM 抽取已跳過。可以在媒體提供者裡填入 OpenAI key或設定環境變數 ANTHROPIC_API_KEY / OPENAI_API_KEY 來啟用。啟發式抽取仍在執行。',
'settings.memoryExtractionProviderOverride': '記憶設定',
'settings.memoryExtractionDelete': '刪除',
'settings.memoryExtractionsClear': '清空',
'settings.memoryExtractionsClearTitle': '清空整個抽取歷史',
'settings.libraryInstall': '安裝',
'settings.libraryInstallGithub': 'GitHub',
'settings.libraryInstallLocal': '本機路徑',
@ -1236,6 +1311,15 @@ export const zhTW: Dict = {
'notify.failureTitle': '任務失敗',
'notify.successBody': '一輪回答已經寫完。',
'notify.failureBody': '本輪任務出錯,請查看錯誤訊息。',
'settings.memoryModelInlineLabel': 'Memory 模型',
'settings.memoryModelInlineSameAsChat': '與聊天一致',
'settings.memoryModelInlineSameAsChatWithModel': '與聊天一致({model}',
'settings.memoryModelInlineSameAsChatWithProvider': '與聊天一致({provider}',
'settings.memoryModelInlineHintCli': '可選。Memory 擷取仍會使用環境變數或媒體設定裡的 API key 呼叫對應供應商;在這裡選模型只是覆寫自動挑選的預設值。',
'settings.memoryModelInlineHintCliConstrained': '可選。Memory 會呼叫 {provider};需要對應的環境變數或媒體設定裡的 API key或在下方選一個模型覆寫。',
'settings.memoryModelInlineHintByok': '可選。沿用你聊天用的 API key在同供應商上換成通常更便宜的模型跑背景 memory 擷取。',
'settings.memoryModelInlineFlashSaved': '已儲存',
'settings.memoryModelInlineFlashCleared': '已清除',
'settings.orbit.eyebrow': '自動化',
'settings.orbit.title': 'Orbit',
'settings.orbit.navHint': '每日連接器摘要',

View file

@ -329,6 +329,97 @@ export interface Dict {
'settings.orbit.controlsLockedBadge': string;
'settings.orbit.controlsLockedHint': string;
// Memory (auto-extracted personalization saved as on-disk markdown)
'settings.memory': string;
'settings.memoryHint': string;
'settings.memoryDescription': string;
'settings.memoryEnabled': string;
'settings.memoryDisabled': string;
'settings.memoryEnableLabel': string;
'settings.memoryDisabledBanner': string;
'settings.memoryNew': string;
'settings.memoryEdit': string;
'settings.memoryDelete': string;
'settings.memoryPreview': string;
'settings.memoryEmpty': string;
'settings.memoryEmptyHintZh': string;
'settings.memoryEmptyHintEn': string;
'settings.memoryName': string;
'settings.memoryDesc': string;
'settings.memoryBody': string;
'settings.memoryBodyHint': string;
'settings.memoryStartersLabel': string;
'settings.memoryStarterUserName': string;
'settings.memoryStarterUserDesc': string;
'settings.memoryStarterUserBody': string;
'settings.memoryStarterFeedbackName': string;
'settings.memoryStarterFeedbackDesc': string;
'settings.memoryStarterFeedbackBody': string;
'settings.memoryStarterProjectName': string;
'settings.memoryStarterProjectDesc': string;
'settings.memoryStarterProjectBody': string;
'settings.memorySaveHint': string;
'settings.memoryIndexSaveHint': string;
'settings.memoryIndexUnsaved': string;
'settings.memoryFlashCreated': string;
'settings.memoryFlashSaved': string;
'settings.memoryFlashDeleted': string;
'settings.memoryFlashIndexSaved': string;
'settings.memoryNameLabel': string;
'settings.memoryTypeLabel': string;
'settings.memoryDescLabel': string;
'settings.memoryBodyLabel': string;
'settings.memoryTypeUser': string;
'settings.memoryTypeFeedback': string;
'settings.memoryTypeProject': string;
'settings.memoryTypeReference': string;
'settings.memoryIndex': string;
'settings.memoryIndexSave': string;
'settings.memoryIndexReset': string;
'settings.memoryToastChanged': string;
'settings.memoryToastClickHint': string;
'settings.memoryAll': string;
// Memory → LLM extraction observability
'settings.memoryExtractions': string;
'settings.memoryExtractionsHint': string;
'settings.memoryExtractionsEmpty': string;
'settings.memoryExtractionsRefresh': string;
'settings.memoryExtractionPhaseRunning': string;
'settings.memoryExtractionPhaseSuccess': string;
'settings.memoryExtractionPhaseSkipped': string;
'settings.memoryExtractionPhaseFailed': string;
'settings.memoryExtractionSkipNoProvider': string;
'settings.memoryExtractionSkipDisabled': string;
'settings.memoryExtractionSkipEmpty': string;
'settings.memoryExtractionSkipNoMatch': string;
'settings.memoryExtractionKindHeuristic': string;
'settings.memoryExtractionKindLlm': string;
'settings.memoryExtractionProviderEnv': string;
'settings.memoryExtractionProviderMediaConfig': string;
'settings.memoryExtractionProviderOverride': string;
'settings.memoryExtractionProposed': string;
'settings.memoryExtractionWritten': string;
'settings.memoryExtractionDuration': string;
'settings.memoryExtractionDelete': string;
'settings.memoryExtractionsClear': string;
'settings.memoryExtractionsClearTitle': string;
'settings.memoryNoProviderBannerTitle': string;
'settings.memoryNoProviderBannerBody': string;
// Memory model picker — rendered inline next to the chat model picker
// so picking "the fast model that mines facts each turn" lives in the
// same row as the chat agent + model. Reuses the surrounding chat
// protocol/CLI context (key, baseUrl, apiVersion); the user only
// chooses the model id.
'settings.memoryModelInlineLabel': string;
'settings.memoryModelInlineSameAsChat': string;
'settings.memoryModelInlineSameAsChatWithModel': string;
'settings.memoryModelInlineSameAsChatWithProvider': string;
'settings.memoryModelInlineHintCli': string;
'settings.memoryModelInlineHintCliConstrained': string;
'settings.memoryModelInlineHintByok': string;
'settings.memoryModelInlineFlashSaved': string;
'settings.memoryModelInlineFlashCleared': string;
// MCP server settings
'settings.mcpTitle': string;
'settings.mcpHint': string;

View file

@ -247,7 +247,32 @@ input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
select { padding-right: 24px; }
/* Native <select> on macOS uses its own intrinsic min-height that ends up
shorter than <input> at the same `padding: 7px 10px` rule above, so any
form that flex-aligns an input next to a select (e.g. the memory editor's
Title + Type row) renders with mismatched heights. Stripping the native
chrome lets the shared padding and inherited line-height compute the
same box dimensions as the input, then we paint our own chevron via a
background SVG. The chevron color is a per-theme override so it stays
readable against the panel in both light and dark. */
select {
padding-right: 32px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2374716b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
select::-ms-expand { display: none; }
[data-theme='dark'] select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
}
}
textarea { resize: vertical; font-family: inherit; }
code {
@ -11301,6 +11326,17 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
border-color: var(--accent);
color: white;
}
/* The global `button:hover:not(:disabled)` rule (specificity 0,2,1) is more
specific than `.filter-pill.active` (0,2,0), so without an explicit
active-hover rule the active pill loses its accent background on hover and
the white label drops onto a cream `--bg-subtle` background invisible.
This rule (0,3,0) wins and keeps the accent treatment, swapping in
`--accent-hover` for a subtle hover shift. */
.filter-pill.active:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: white;
}
.filter-pill-count {
font-size: 11px;
opacity: 0.7;
@ -14105,6 +14141,32 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
margin-bottom: 16px;
}
/* Variant: filter pills on the left, primary action on the right.
Used by Memory's toolbar where there is no search input above. */
.library-toolbar.is-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.library-toolbar.is-row .library-filters {
flex: 1;
min-width: 0;
}
/* Primary action button positioned at the right edge of `.library-toolbar`.
The default button line-height makes the icon and label vertically off
when sitting next to filter pills; tightening the line-height aligns the
plus icon with the text baseline. */
.library-toolbar-action {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.library-search {
width: 100%;
padding: 8px 12px;
@ -14157,14 +14219,29 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
.library-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
flex-wrap: wrap;
}
/* Icon-only action buttons inside a library-card.
The chevron/edit/close buttons used different sizes (.library-card-expand
was 4px padding, .ghost was 6px 12px), which made them look misaligned
horizontally and stretched the card vertically. Force a uniform 28x28
square so they line up like a proper action group. */
.library-card .library-card-action {
flex: 0 0 28px;
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.library-card.disabled {
opacity: 0.45;
}
@ -14211,13 +14288,20 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background: none;
border: none;
cursor: pointer;
padding: 4px;
padding: 0;
flex: 0 0 28px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
border-radius: 4px;
border-radius: 6px;
}
.library-card-expand:hover {
background: var(--border);
background: var(--bg-subtle);
color: var(--text);
}
.library-ds-card {
@ -14297,6 +14381,167 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
padding: 32px 0;
}
/* Memory section: warning banner shown when the memory feature is
disabled. Padded so it reads as a full callout rather than inline text,
and tinted with the same warning hue used elsewhere in settings. */
.memory-disabled-banner {
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 159, 64, 0.12);
color: var(--text-muted, #b06a00);
border: 1px solid rgba(255, 159, 64, 0.32);
font-size: 12px;
margin: 8px 0 12px;
}
/* Memory section: red callout shown when the LLM extractor is silent
because no API key is configured. Same shape as `memory-disabled-banner`
but tuned to a stronger warning hue so the user reads it as
"something is wrong" rather than "FYI". */
.memory-noprovider-banner {
padding: 8px 12px;
border-radius: 8px;
background: rgba(220, 53, 69, 0.10);
color: var(--text-danger, #b02a37);
border: 1px solid rgba(220, 53, 69, 0.32);
font-size: 12px;
margin: 8px 0 12px;
}
/* Memory section: monospace path showing where the memory files live on
disk. `break-all` so even long absolute paths fit inside the panel. */
.memory-root-dir {
word-break: break-all;
font-size: 11px;
}
/* Memory section: extraction-history list. One row per recorded LLM
extraction attempt, with phase pill + provider/model meta + preview of
the user message that triggered it. Kept dense (no card) so a burst of
attempts stays scannable inside the small <details> drawer. */
.memory-extraction-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.memory-extraction-item {
padding: 8px 10px;
border-radius: 8px;
background: var(--surface-subtle, rgba(0, 0, 0, 0.02));
border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.06));
display: flex;
flex-direction: column;
gap: 4px;
}
.memory-extraction-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.memory-extraction-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
background: var(--surface-strong, rgba(0, 0, 0, 0.06));
color: var(--text-muted, #888);
}
.memory-extraction-pill.is-running {
background: rgba(64, 137, 255, 0.16);
color: #2864d6;
}
.memory-extraction-pill.is-success {
background: rgba(34, 139, 90, 0.16);
color: #1f7a4d;
}
.memory-extraction-pill.is-skipped {
background: rgba(150, 150, 150, 0.16);
color: var(--text-muted, #888);
}
.memory-extraction-pill.is-failed {
background: rgba(220, 53, 69, 0.14);
color: #b02a37;
}
.memory-extraction-meta {
font-size: 11px;
color: var(--text-muted, #888);
}
/* Subtle filled pill so the heuristic/LLM badge reads as a kind tag,
* not a free-floating word in the meta row. Keeps the visual weight
* lower than the phase pill on the left (no colour signal, just a
* tonal background) so it stays a passive label. */
.memory-extraction-kind {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
background: var(--surface-subtle, rgba(0, 0, 0, 0.05));
border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.08));
font-weight: 500;
letter-spacing: 0.2px;
text-transform: uppercase;
font-size: 10px;
color: var(--text-muted, #888);
}
.memory-extraction-preview {
font-size: 12px;
color: var(--text, inherit);
line-height: 1.4;
word-break: break-word;
}
.memory-extraction-reason {
font-size: 11px;
color: var(--text-muted, #888);
font-style: italic;
}
.memory-extraction-counts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted, #888);
}
.memory-extraction-ids {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* Memory section: pill that briefly confirms a manual save/create/delete.
Pinned to the right edge of the section's flex column. */
.memory-flash-pill {
align-self: flex-end;
margin: 4px 0 8px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(34, 139, 90, 0.12);
color: var(--text-success, #1f7a4d);
border: 1px solid rgba(34, 139, 90, 0.32);
font-size: 12px;
font-weight: 500;
}
/* Library install UI */
.library-toolbar-row {

View file

@ -0,0 +1,164 @@
// Shared metadata for the four API protocols the BYOK pickers offer.
//
// Originally these tables lived inline in `SettingsDialog.tsx`. The
// memory-extraction picker needs the exact same lists (so it can mirror
// the chat picker's protocol tabs / suggested-model dropdown / API key
// placeholders) — extracting them here keeps the two pickers from
// drifting apart whenever someone adds a new fast-pass model or a new
// quick-fill provider.
//
// The lists are intentionally hand-curated rather than auto-discovered:
// every option exposes `provider/model` strings the daemon already
// understands, so a new entry here implies a deliberate decision about
// support on the request side too.
import type { ApiProtocol } from '../types';
// Suggested fast-pass / common models per protocol — what the BYOK
// model dropdown lists by default. The first OpenAI-compatible block is
// duplicated under both `openai` and `azure` because azure's chat-
// completions endpoint speaks the same JSON shape; the deployment name
// the user types in the model field is what's variable, not the API.
export const SUGGESTED_MODELS_BY_PROTOCOL: Record<ApiProtocol, readonly string[]> = {
anthropic: [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.7',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.1-highspeed',
'MiniMax-M2.1',
'MiniMax-M2',
'mimo-v2.5-pro',
],
openai: [
'gpt-4o',
'gpt-4o-mini',
'o3',
'o4-mini',
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.7',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.1-highspeed',
'MiniMax-M2.1',
'MiniMax-M2',
'mimo-v2.5-pro',
],
azure: [
'gpt-4o',
'gpt-4o-mini',
],
google: [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-1.5-pro',
'gemini-1.5-flash',
],
ollama: [
'cogito-2.1:671b',
'deepseek-v3.1:671b',
'deepseek-v3.2',
'deepseek-v4-flash',
'deepseek-v4-pro',
'devstral-2:123b',
'devstral-small-2:24b',
'gemini-3-flash-preview',
'gemma3:4b',
'gemma3:12b',
'gemma3:27b',
'gemma4:31b',
'glm-4.6',
'glm-4.7',
'glm-5',
'glm-5.1',
'gpt-oss:20b',
'gpt-oss:120b',
'kimi-k2:1t',
'kimi-k2-thinking',
'kimi-k2.5',
'kimi-k2.6',
'minimax-m2',
'minimax-m2.1',
'minimax-m2.5',
'minimax-m2.7',
'ministral-3:3b',
'ministral-3:8b',
'ministral-3:14b',
'mistral-large-3:675b',
'nemotron-3-nano:30b',
'nemotron-3-super',
'qwen3-coder:480b',
'qwen3-coder-next',
'qwen3-next:80b',
'qwen3-vl:235b',
'qwen3-vl:235b-instruct',
'qwen3.5:397b',
'rnj-1:8b',
],
};
// "Fast / cheap" model recommendation for each protocol. Used by the
// memory extractor's auto-mode pill ("we'll quietly run gpt-4o-mini on
// your OpenAI key") and by anyone else who needs a one-pick default
// that prioritises latency + cost over reasoning depth.
export const FAST_MODEL_BY_PROTOCOL: Record<ApiProtocol, string> = {
anthropic: 'claude-haiku-4-5',
openai: 'gpt-4o-mini',
azure: 'gpt-4o-mini',
google: 'gemini-2.0-flash',
// Ollama Cloud doesn't have a clean "fast small model" default that
// works for the LLM memory extractor — the catalog skews to large
// open-weight checkpoints. Fall back to a small Gemma so the auto-
// pick produces a deterministic answer; users who care can override
// through the Memory model picker.
ollama: 'gemma3:4b',
};
export const API_PROTOCOL_TABS: ReadonlyArray<{
id: ApiProtocol;
title: string;
}> = [
{ id: 'anthropic', title: 'Anthropic' },
{ id: 'openai', title: 'OpenAI' },
{ id: 'azure', title: 'Azure OpenAI' },
{ id: 'google', title: 'Google Gemini' },
{ id: 'ollama', title: 'Ollama Cloud' },
];
export const API_PROTOCOL_LABELS: Record<ApiProtocol, string> = {
anthropic: 'Anthropic API',
openai: 'OpenAI API',
azure: 'Azure OpenAI',
google: 'Google Gemini',
ollama: 'Ollama Cloud API',
};
export const API_KEY_PLACEHOLDERS: Record<ApiProtocol, string> = {
anthropic: 'sk-ant-...',
openai: 'sk-...',
azure: 'azure key',
google: 'AIza...',
ollama: 'Ollama API key',
};
// Default base URL the daemon assumes when the user leaves the field
// blank. Kept here so the BYOK form can render it as a placeholder
// hint and keep the two surfaces (form vs. daemon) in sync.
export const DEFAULT_BASE_URL_BY_PROTOCOL: Record<ApiProtocol, string> = {
anthropic: 'https://api.anthropic.com',
openai: 'https://api.openai.com',
azure: '',
google: 'https://generativelanguage.googleapis.com',
ollama: 'https://ollama.com',
};

View file

@ -660,7 +660,18 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
it('runs the BYOK connection test only after required fields are present', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
expect(input.toString()).toBe('/api/test/connection');
const url = input.toString();
// MemoryModelInline mounts inside the BYOK section and reads the
// current extraction override from /api/memory on mount. Swallow
// it here so the assertion below only counts the test-connection
// POST the user actually triggered.
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
expect(url).toBe('/api/test/connection');
expect(JSON.parse(String(init?.body))).toMatchObject({
mode: 'provider',
protocol: 'anthropic',
@ -694,7 +705,10 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
await waitFor(() => {
expect(screen.getByText(/Connected\. Replied in 42 ms/)).toBeTruthy();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const testConnectionCalls = fetchMock.mock.calls.filter(
([input]) => input.toString() === '/api/test/connection',
);
expect(testConnectionCalls).toHaveLength(1);
});
});
@ -864,7 +878,18 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
it('runs the Local CLI connection test for the selected installed agent', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
expect(input.toString()).toBe('/api/test/connection');
const url = input.toString();
// MemoryModelInline mounts inside the Local CLI section and reads
// the current extraction override from /api/memory on mount.
// Swallow it here so the assertion below only counts the
// test-connection POST the user actually triggered.
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
expect(url).toBe('/api/test/connection');
expect(JSON.parse(String(init?.body))).toMatchObject({
mode: 'agent',
agentId: 'codex',
@ -898,7 +923,10 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
await waitFor(() => {
expect(screen.getByText(/Codex CLI replied in 31 ms/)).toBeTruthy();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const testConnectionCalls = fetchMock.mock.calls.filter(
([input]) => input.toString() === '/api/test/connection',
);
expect(testConnectionCalls).toHaveLength(1);
});
});

View file

@ -80,7 +80,9 @@ test('legacy known OpenAI provider switches to the matching Anthropic preset', a
const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = dialog.getByLabel('Base URL');
const modelSelect = dialog.getByLabel('Model');
// Use getByRole + exact so we only match the chat "Model" picker and
// not the inline "Memory model" picker that sits next to it.
const modelSelect = dialog.getByRole('combobox', { name: 'Model', exact: true });
await expect(openAiTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
@ -151,7 +153,7 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
await dialog.getByLabel('Quick fill provider').selectOption('1');
await expect(dialog.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(dialog.getByRole('combobox', { name: 'Model', exact: true })).toHaveValue('deepseek-chat');
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await dialog.getByRole('button', { name: 'Show' }).click();
@ -188,7 +190,7 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
const reopenedDialog = page.getByRole('dialog');
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(reopenedDialog.getByLabel('Quick fill provider')).toHaveValue('1');
await expect(reopenedDialog.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(reopenedDialog.getByRole('combobox', { name: 'Model', exact: true })).toHaveValue('deepseek-chat');
await expect(reopenedDialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await expect(reopenedDialog.getByLabel('API key')).toHaveValue('sk-openai-test');
});

View file

@ -0,0 +1,355 @@
// File-system markdown memory store.
//
// One .md file per fact under <dataDir>/memory/, plus an index file at
// <dataDir>/memory/MEMORY.md. The index is a hand-edited Table of
// Contents — one bullet per fact. The per-fact file holds the body
// itself plus a small frontmatter block (`name`, `description`, `type`).
//
// Inspired by the pattern Claude Code's auto-memory skill uses; see also
// llm_wiki, gbrain, memU. Kept deliberately small so every read/write
// stays a plain `cat` / `editor` round trip — no DB, no fancy schema.
export type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
export const MEMORY_TYPES: readonly MemoryType[] = [
'user',
'feedback',
'project',
'reference',
] as const;
// Listing payload — frontmatter only, no body. The settings panel pulls
// the full body lazily through `GET /api/memory/:id` when the user
// opens the preview/edit drawer.
export interface MemoryEntrySummary {
/** File slug, without the `.md` suffix. e.g., "user_role" or "feedback_tests". */
id: string;
/** Human display title pulled from frontmatter `name`. */
name: string;
/** One-line description pulled from frontmatter `description`. */
description: string;
/** Category — drives the filename prefix and the system-prompt section it lands in. */
type: MemoryType;
/** Unix milliseconds — file mtime. */
updatedAt: number;
}
export interface MemoryEntry extends MemoryEntrySummary {
/** Markdown body, frontmatter stripped. */
body: string;
}
// GET /api/memory
export interface MemoryListResponse {
/** True when the daemon will inject memory into the next system prompt. */
enabled: 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. */
index: string;
entries: MemoryEntrySummary[];
/** User-supplied override for the LLM extraction provider. `null` when
* the daemon should auto-pick (env var media-config fallback). API
* keys returned here are masked only the last 4 characters are
* echoed back so the UI can show a "configured" affordance without
* leaking the secret into the DOM. */
extraction: MemoryExtractionMaskedConfig | null;
}
/** Provider/protocol the memory extractor calls. Mirrors the chat
* BYOK form's protocols anthropic + openai-compatible + azure
* (openai-compatible at a different URL/header) + google gemini +
* ollama (also openai-compatible, just hosted on Ollama Cloud) so
* the memory picker can offer the same options as the chat picker
* above it. The daemon routes ollama through the same callOpenAI
* path since the wire protocol is identical. */
export type MemoryExtractionProvider =
| 'anthropic'
| 'openai'
| 'azure'
| 'google'
| 'ollama';
/** Masked version of MemoryExtractionConfig returned by GET endpoints
* the api key field is replaced with a 4-char tail so the settings UI
* can render "•••• abcd" without echoing the secret back into the DOM. */
export interface MemoryExtractionMaskedConfig {
provider: MemoryExtractionProvider;
model: string;
baseUrl: string;
/** Azure-only: the `?api-version=` query param value. Empty for the
* other providers. The daemon falls back to a sensible default when
* this is empty even on azure. */
apiVersion: string;
/** Last 4 chars of the configured key, or empty when unset. */
apiKeyTail: string;
/** True when an apiKey is stored in the override config. */
apiKeyConfigured: boolean;
}
// GET /api/memory/:id
export interface MemoryEntryResponse {
entry: MemoryEntry;
}
// POST /api/memory → upsert (id supplied → update; missing → create)
// PUT /api/memory/:id → update by id
export interface UpsertMemoryRequest {
/** Optional on create — daemon derives a slug from `type` + `name`. */
id?: string;
name: string;
description: string;
type: MemoryType;
body: string;
}
export interface UpsertMemoryResponse {
entry: MemoryEntry;
}
// PUT /api/memory/index — overwrite MEMORY.md with arbitrary content. The
// settings UI uses this when the user hand-edits the index in the textarea.
export interface UpdateMemoryIndexRequest {
index: string;
}
// PATCH /api/memory/config — toggle whether memory is folded into prompts,
// and/or override the LLM extraction provider.
export interface UpdateMemoryConfigRequest {
enabled?: 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;
}
export interface MemoryConfigResponse {
enabled: boolean;
extraction: MemoryExtractionConfig | null;
}
// User-supplied override for the LLM extraction model. When `null`/absent,
// the daemon falls back to its auto-pick: ANTHROPIC_API_KEY env var →
// OPENAI_API_KEY env var → the OpenAI key configured under Settings →
// Media providers. When set, every field is honored verbatim.
//
// Fields are deliberately optional past `provider` so the daemon can fall
// back per-field to environment defaults when the user hasn't typed them
// in (e.g., they pasted a model name but want to keep using the
// ANTHROPIC_API_KEY in their env). Empty strings are treated as "not set".
export interface MemoryExtractionConfig {
provider: MemoryExtractionProvider;
/** Override model id. When empty, the daemon picks a provider default
* (`claude-haiku-4-5` for anthropic; `gpt-4o-mini` for openai/azure;
* `gemini-2.0-flash` for google). For azure this is the deployment
* name, not the underlying model family. */
model?: string;
/** Override base URL. When empty, the daemon uses the provider default
* (no default for azure the user must supply their resource URL). */
baseUrl?: string;
/** Override API key. When empty, the daemon falls back to env vars and
* the media-config OpenAI key (openai provider only). */
apiKey?: string;
/** Azure-only: `?api-version=…` query string. Ignored for other providers. */
apiVersion?: string;
}
export interface DeleteMemoryResponse {
ok: true;
}
// POST /api/memory/extract — fired by the chat run after each user turn.
// The daemon also calls this internally; exposing it as an HTTP endpoint
// makes it cheap to test with `curl` and lets a future settings UI replay
// extraction over an old turn.
export interface ExtractMemoryRequest {
userMessage: string;
assistantMessage?: string;
projectId?: string | null;
conversationId?: string | null;
/** BYOK chat config snapshot. The web app sends this with every
* BYOK / API-mode extraction call so the daemon can run LLM
* extraction against the *current* chat provider/key/baseUrl/
* apiVersion when no explicit memory model override is set
* i.e. the picker is on "Same as chat". Without it the daemon's
* `pickProvider()` falls back to env vars or the media-config
* OpenAI key, which is wrong for BYOK chats whose creds the
* daemon never persists.
*
* When the user has set an explicit memory model (override
* exists), the override always wins and this field is ignored.
* CLI-mode extraction calls leave this empty the agent-id
* constrained branch in `pickProvider()` handles those.
*
* An empty `apiKey` (or missing field) is treated as "no usable
* BYOK config" and falls through to the legacy provider chain so
* a half-configured BYOK form doesn't silently break extraction. */
chatProvider?: {
provider: MemoryExtractionProvider;
apiKey?: string;
baseUrl?: string;
/** Azure-only `?api-version=…` value. Ignored for other providers. */
apiVersion?: string;
/** Optional the daemon prefers a fast/cheap default per protocol
* (`claude-haiku-4-5` / `gpt-4o-mini` / etc.) over the chat model
* the user is paying for. Pass this only when the caller
* explicitly wants the same model used for both. */
model?: string;
};
}
export interface ExtractMemoryResponse {
/** Entries created or updated by this extraction pass. Empty when the
* heuristic found nothing worth saving. */
changed: MemoryEntrySummary[];
/** True when the daemon also kicked off the background LLM extractor
* for this turn i.e. the caller supplied both a non-empty
* `userMessage` and `assistantMessage`. The LLM extractor runs out
* of band; observe `MemoryExtractionEvent` on `/api/memory/events`
* for its result. */
attemptedLLM?: boolean;
}
// GET /api/memory/system-prompt — composed markdown block the daemon
// would fold into the chat system prompt for a CLI run. Returns ''
// when memory is disabled, missing, or nothing in the index is linked
// in. BYOK / API-mode chats fetch this before each turn so the same
// memory the daemon-side chat enjoys is also injected when
// `ProjectView` composes the prompt locally.
export interface MemorySystemPromptResponse {
body: string;
}
// SSE feed payload — emitted on `/api/memory/events` whenever the daemon
// mutates memory (chat-driven extraction, manual settings edits, LLM
// extractor, or `curl` POSTs). The web UI subscribes to this so changes
// in any tab show up in any other open tab without polling.
export type MemoryChangeKind =
| 'upsert'
| 'delete'
| 'index'
| 'config'
| 'extract';
export interface MemoryChangeEvent {
kind: MemoryChangeKind;
/** Present on `upsert` and `delete`. */
id?: string;
/** Mirrored from the entry frontmatter on `upsert`. */
name?: string;
description?: string;
type?: MemoryType;
/** Number of entries written in this pass — only on `kind: 'extract'`. */
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';
/** Only on `kind: 'config'` — the new enabled flag. */
enabled?: boolean;
/** Unix milliseconds. */
at: number;
}
// ----- Extraction observability -------------------------------------------
//
// Two extractors share this surface:
//
// - 'heuristic' — the regex pack in `apps/daemon/src/memory.ts`. Runs
// synchronously in the chat route before the prompt is composed.
// - 'llm' — the small-model extractor in `apps/daemon/src/memory-llm.ts`.
// Runs in the background after the run finishes.
//
// Both used to be silent — if either was skipped (no API key, message
// empty) or returned 0 hits, the user had no way to tell whether their
// turn was processed at all. The records below are written by both
// extractors so the settings panel can render a single "recent
// extractions" list with `kind` badges, surfacing skips, failures, and
// zero-match runs.
/** Which extractor produced the attempt. `'llm'` is the legacy default
* for records written before this field existed. */
export type MemoryExtractionKind = 'heuristic' | 'llm';
export type MemoryExtractionPhase =
| 'running'
| 'success'
| 'skipped'
| 'failed'
// Pseudo-phase emitted only on the SSE `extraction` channel when a row
// is removed from the buffer (manual delete or full clear). Persisted
// records never carry these phases — the daemon evicts them straight
// out of the ring buffer rather than rewriting them in place.
| 'deleted'
| 'cleared';
/** Why an attempt was skipped before any LLM call (or, for the regex
* extractor, before any pattern was tested). Surface this in the UI so
* the user can see "we'd run extraction but no API key is configured"
* instead of staring at a memory list that mysteriously stopped
* growing. `'no-match'` is heuristic-only the regex ran but every
* pattern produced 0 captures. */
export type MemoryExtractionSkipReason =
| 'no-provider'
| 'memory-disabled'
| 'empty-message'
| 'no-match';
export interface MemoryExtractionRecord {
/** Stable id for the attempt. UUID-ish; safe to use as a React key. */
id: string;
/** Which extractor wrote this record. Optional for backwards compat
* with daemons that predate the heuristic surfacing the UI treats
* a missing kind as `'llm'` since that was the only writer. */
kind?: MemoryExtractionKind;
/** Unix milliseconds — when the attempt was queued. */
startedAt: number;
/** Unix milliseconds — when the attempt reached a terminal phase. */
finishedAt?: number;
phase: MemoryExtractionPhase;
/** Populated when phase === 'skipped'. */
reason?: MemoryExtractionSkipReason;
/** Provider+model resolved at attempt time. Absent when kind ===
* 'heuristic' (the regex pack has no provider) or when the LLM
* attempt was skipped before provider selection. */
provider?: {
kind: MemoryExtractionProvider;
model: string;
/** Where the credential came from. `'memory-config'` = the explicit
* override under Settings Memory; `'env'` = ANTHROPIC_API_KEY /
* OPENAI_API_KEY in the daemon's environment; `'media-config'` =
* 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';
};
/** First ~120 chars of the user's message for display in the list. */
userMessagePreview: string;
/** How many entries the model proposed (after JSON parse + type filter).
* Heuristic records never set this they go straight from match to write. */
proposedCount?: number;
/** How many entries actually landed on disk (proposed minus already-known dedupes). */
writtenCount?: number;
/** Slugs of entries written in this attempt — clickable in the UI. */
writtenIds?: string[];
/** Populated when phase === 'failed'. Single-line, ≤ 240 chars. */
error?: string;
}
// GET /api/memory/extractions — most-recent-first. Capped server-side.
export interface MemoryExtractionsResponse {
/** Most recent first. */
extractions: MemoryExtractionRecord[];
}
// DELETE /api/memory/extractions/:id — remove one history record.
// DELETE /api/memory/extractions — clear the whole buffer.
// Returns the number of records removed (0 when the id was already gone).
export interface DeleteMemoryExtractionResponse {
removed: number;
}
// SSE event name `extraction` on /api/memory/events. Emitted on every
// phase transition; the latest record for a given id supersedes earlier
// ones. The frontend deduplicates by id so a buffered burst of phase
// updates collapses into a single visible row.
export interface MemoryExtractionEvent extends MemoryExtractionRecord {}

View file

@ -11,6 +11,7 @@ export * from './api/files';
export * from './api/finalize';
export * from './api/live-artifacts';
export * from './api/mcp';
export * from './api/memory';
export * from './api/orbit';
export * from './api/providerModels';
export * from './api/projects';

View file

@ -51,6 +51,12 @@ export interface ComposeInput {
| undefined;
designSystemBody?: string | undefined;
designSystemTitle?: string | undefined;
// Personal-memory block (auto-extracted facts + the hand-edited
// MEMORY.md index). The daemon side composes this on disk and the
// BYOK side fetches it from `GET /api/memory/system-prompt`; either
// way the string is folded in right after the base charter so the
// model treats it as preferences/context rather than hard rules.
memoryBody?: string | undefined;
// Project-level metadata captured by the new-project panel. Drives the
// agent's understanding of artifact kind, fidelity, speaker-notes intent
// and animation intent. Missing fields here are exactly what the
@ -71,6 +77,7 @@ export function composeSystemPrompt({
skillMode,
designSystemBody,
designSystemTitle,
memoryBody,
metadata,
template,
streamFormat,
@ -85,6 +92,19 @@ export function composeSystemPrompt({
BASE_SYSTEM_PROMPT,
];
// Mirrors the daemon-side composer in apps/daemon/src/prompts/system.ts —
// keep both copies of this preamble in sync so a CLI chat and a BYOK
// chat with the same memory both see the same wording. The "brand
// wins on conflict / skill workflow wins on conflict / preferences
// are still authoritative for tone+terminology" framing is what
// stops the model from treating remembered preferences as harder
// than the active design system.
if (memoryBody && memoryBody.trim().length > 0) {
parts.push(
`\n\n## Personal memory (auto-extracted from past chats)\n\nThe following facts have been sedimented from this user's previous conversations and edited in the settings panel. Treat them as preferences and context, NOT hard rules: when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins. They are still authoritative for tone, voice, terminology, and what the user already told you about themselves and their goals — never re-ask the user about something already captured here.\n\n${memoryBody.trim()}`,
);
}
if (designSystemBody && designSystemBody.trim().length > 0) {
parts.push(
`\n\n## Active design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${designSystemBody.trim()}`,