diff --git a/README.md b/README.md index fd181b5..b4421f0 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,151 @@

- Meldwork + Meldwork - Agents change. Work continues. +

+ +

+ English · 简体中文

# Meldwork -**Bring the local Agent CLIs already on your machine into one persistent workspace.** +**Agents change. Work continues.** -Meldwork is building an open, local-first work system for agents across vendors. The current MVP brings multiple local AI Agent CLIs into the same local conversation and working directory, discovers the Agents already available on your computer, keeps conversations persistent, preserves resumable native sessions where compatible CLIs expose them, and coordinates manual or bounded automatic discussions without requiring a hosted orchestration service. +Meldwork is a local-first desktop workspace for general-purpose AI agents. -The long-term product direction is an open-source, local-first work system for agents across vendors. This repository is still `UNLICENSED`; the public open-source launch must not happen until a project license is selected and added. +AI agents are no longer one narrow product category. Tools that began with coding, research, personal assistance, or operations are converging into general-purpose systems that can reason, use tools, handle files, and carry multi-step work. People will use more than one of them. The work still needs one place to continue. -## Product Direction +Meldwork is not another Agent, and it is not a coding-only Agent aggregator. It keeps conversations, compatible native sessions, collaboration state, and human control in one persistent workspace while work moves between different Agents. -The target product promise is **Different agents. Work you can verify.** It describes the evidence-and-acceptance loop Meldwork intends to build, not a claim that the current MVP already verifies Agent output. +> **Current MVP:** Meldwork connects the supported local Agent CLIs listed below. The product direction is an open connector layer for local, cloud, custom, and channel-native Agents, but arbitrary Agent connectors are not shipped yet. This repository also remains `UNLICENSED` until a public software license is selected. -The current MVP proves local Agent discovery, guarded execution, persistent conversations, and native-session continuity where upstream CLIs support it. The next milestone is not a larger adapter count; it is an outcome loop that turns Agent activity into work a user can inspect and adopt: +## Better Agents do not solve fragmented work + +When real work moves between Agents, users still have to: + +- rebuild context across disconnected sessions and interfaces; +- collect decisions, files, and intermediate results from different places; +- decide manually which Agent should answer, challenge, revise, or continue; +- remember which Agent still holds which part of the task; +- reconcile different Provider traffic, permissions, and tool behavior; +- treat a fluent answer as completion because there is no durable work record. + +This affects research, planning, writing, operations, analysis, design, development, and review. Coding is one use case, not the product boundary. + +## One workspace for the work + +| Fragmented Agent use | With the current Meldwork MVP | +| --- | --- | +| Context trapped in separate sessions | Persistent direct and group conversations in one local workspace | +| Re-explaining the task after every switch | Native-session continuity where the upstream Agent exposes a compatible resume mechanism | +| Manually routing work across several interfaces | Direct responses or user-bounded multi-Agent discussions with visible run state | +| Unclear write and Provider behavior | Explicit per-Agent Provider profiles and visible per-conversation write mode where enforceable | +| A hosted orchestration account becomes the new dependency | Groups, messages, and orchestration state remain on the user's machine | + +Local-first does not mean every model runs offline. Network traffic still follows the Agent and Provider configuration the user selects. Meldwork keeps its workspace and control state local. + +## General Agents, modeled by real capabilities + +Codex, Claude Code, Hermes, OpenClaw, and similar tools may have different origins, but their capabilities increasingly extend beyond a single coding workflow. Meldwork treats them as Agents and keeps their actual differences explicit: supported inputs, Skills, session behavior, Provider configuration, permissions, and output handling. + +The name of an upstream tool does not define Meldwork's category. Continuity of work across Agents does. + +## What works today + +### Discover supported local Agents + +Refresh one catalog to find supported Agent CLIs already installed or available through an allowlisted installer recipe. Executable paths stay in the Electron main process. + +### Keep work in persistent conversations + +Create multiple direct conversations for the same Agent, return later, or bring selected Agents into a group around the same body of work. + +### Continue native sessions where possible + +For compatible CLI versions, Meldwork keeps one logical native session per Agent and conversation. A resumed Agent receives only the group messages it has not already seen. + +### Bring in another Agent without rebuilding the workflow + +Run one response or a bounded automatic discussion from 1 to 10 rounds. Each Agent has a visible queued, running, completed, or failed state, and the user decides what to accept. + +### Carry useful context and outputs + +Reference validated local Skills with `@`, attach supported PNG or JPEG images, and collect supported image, audio, or video outputs from the authorized workspace's `.meldwork-output` directory. Capability checks remain Agent-specific. + +### Keep execution boundaries explicit + +Executable paths, attachment paths, session references, and child processes stay in the Electron main process. Compatible Provider credentials use operating-system-backed secure storage and are not returned to the renderer after saving. + +## Any Agent is the direction, not the current claim + +The product principle is simple: + +> **If it can act as an Agent, it should be able to join Meldwork.** + +The current MVP starts with fixed adapters for supported local CLIs. The next architecture milestone is a publicly documented Agent connector contract and capability manifest so new local runtimes, cloud Agents, and channel-native Agents can join without being hardcoded into the client. + +| Horizon | Product outcome | +| --- | --- | +| **Now: local MVP** | Supported local Agent discovery, persistent direct/group conversations, compatible native-session resume, bounded discussions, Skills, images, and explicit local boundaries | +| **Next: open connection layer** | Connector contract, capability manifest, structured tasks, comparable runs, artifacts, evidence, and explicit human acceptance | +| **Later: after validation** | Cloud-Agent handoff, Meldwork Channels, typed workflows, team policy, cost controls, and enterprise governance | + +These later capabilities are roadmap direction, not shipped behavior. + +## Initial Agent connections + +The current codebase contains adapters and allowlisted installer recipes for: + +**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code** + +These are the first supported connections, not Meldwork's definition of what an Agent can be. An implemented adapter is also not the same as release-certified compatibility; availability depends on the upstream CLI, operating system, authentication, model configuration, and version. + +## A practical workflow ```text -Task -> Runs -> Artifacts -> Evidence -> Human acceptance and adoption +Start with one Agent + ↓ +Keep the work in one persistent conversation + ↓ +Bring in another Agent when a different capability or perspective helps + ↓ +Continue in the same workspace instead of rebuilding context + ↓ +Inspect the result and decide what to adopt ``` -Meldwork should use one Agent by default and add independent attempts or adversarial review only when task risk or uncertainty justifies the extra time and cost. Cloud Agents, Channels, Workflows, and team governance remain roadmap options until this loop demonstrates repeatable value. +Use one Agent when one is enough. Add another when the value of a different capability, independent attempt, or adversarial review justifies the extra time and cost. -## Why Meldwork +## Who Meldwork is for -- **One workspace for different Agents.** Bring Codex, Claude Code, Kimi Code, MiMo Code, and other local CLIs into direct or group conversations. -- **Persistent collaborative context.** For compatible CLI versions that expose resumable sessions, continue with one native session per Agent and conversation; resumed prompts add only the group messages that Agent has not already seen. -- **Local orchestration with explicit boundaries.** Agent execution, groups, messages, and orchestration stay on the user's machine. Provider traffic still follows each selected Agent's configuration. -- **Narrow desktop security boundary.** Executable paths, attachment paths, and native session references stay in the Electron main process. Stored Provider credentials are never returned to the renderer after saving; newly entered credentials cross only the validated Provider IPC. The renderer has no unrestricted shell access. +- People who regularly move research, planning, creation, operations, development, or review work between multiple Agents. +- AI-native teams whose members use different Agent vendors or specialized capabilities. +- Work that benefits from a second perspective without losing the original context. +- Users who want to preserve existing Agent accounts, Provider relationships, and a local workspace boundary. -## Core Capabilities +Meldwork is not yet the right choice if you only use one Agent for isolated tasks, need a managed cloud execution queue today, or require audited enterprise controls and release-certified Windows support. -- Discover installed Agent CLIs without exposing executable paths to the renderer. -- Create multiple persistent direct conversations per Agent, then rename or delete each local conversation independently. -- Run one response at a time or an automatic discussion with a user-selected 1-10 round limit (6 by default). -- Preserve one logical native session per Agent and conversation where the selected CLI supports resume, with automatic migration from older topic-scoped references. -- Show per-Agent queued/running/completed state, automatic round progress, background direct-chat completion markers, and local desktop notifications. -- Reference validated local Skills with `@` and attach PNG or JPEG images with capability checks. -- Request read-only modes by default where an Agent can enforce them, with explicit write opt-in only when the adapter can preserve the workspace boundary. These are adapter-level controls, not an operating-system sandbox. -- Install supported missing Agents through fixed, allowlisted recipes. Upstream packages and scripts are not yet digest- or signature-pinned. -- Store compatible provider credentials with operating-system-backed secure storage. +## Try the MVP locally -## Supported Agents - -| Agent | Adapter implemented | Installer recipes | Release validation | Provider and permission behavior | -| --- | --- | --- | --- | --- | -| Codex | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Responses-compatible configuration | -| Hermes | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider | -| OpenClaw | Yes | macOS and Windows | Pending target-device matrix | Supports a managed OpenAI-compatible Provider | -| WorkBuddy | Yes | macOS and Windows | Pending target-device matrix | Experimental shared Provider support | -| Kimi Code | Yes | macOS and Windows | Pending target-device matrix | Uses its native configuration and ACP plan mode for read-only work | -| MiMo Code | Yes | macOS and Windows | Pending target-device matrix | Uses native configuration; Meldwork invokes the `plan` Agent and does not enable the global permission-bypass flag | -| Claude Code | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Anthropic-compatible configuration | -| Gemini CLI | Yes | macOS and Windows | Pending target-device matrix | Uses its existing authentication and Provider settings | -| OpenCode | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Provider settings | -| Qwen Code | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider | - -These entries describe implemented adapters and installer recipes, not release-certified compatibility. Availability still depends on each upstream CLI, operating-system support, authentication, model configuration, and version compatibility. Live-Agent compatibility and real-device Windows validation remain pending. - -## Privacy And Architecture - -```text -Vue renderer - | validated preload APIs -Electron main process - |-- Agent discovery and execution - |-- local group and message storage - |-- local Skill discovery and validation - |-- app-owned local image attachment storage and previews - |-- provider credential storage - `-- user-authorized provider and installer network access -``` - -The Electron main process owns filesystem paths, child processes, Agent configuration, and persistence. Provider credentials use Electron `safeStorage`; if operating-system encryption is unavailable, Meldwork refuses to persist them in plaintext. Conversations and participants remain local. Network activity occurs only through the selected Agent or Provider, guided installer downloads, or explicit external links. - -Internal compatibility identifiers such as `window.roundrelayDesktop`, existing storage filenames, and `com.roundrelay.desktop` are intentionally retained during the brand transition so current local data and the preload contract continue to work. - -## Development - -Prerequisites: Node.js 20.19 or newer, npm, and macOS for the currently configured release packaging targets. +Prerequisites: Node.js 20.19 or newer, npm, and macOS for the currently configured desktop packaging target. ```bash npm --prefix frontend ci npm --prefix desktop ci -# Renderer-only preview -npm --prefix frontend run dev - -# Electron desktop application +# Start the Electron desktop app npm --prefix desktop run dev ``` -The browser preview cannot execute Agents because it intentionally has no Electron bridge. +For a renderer-only preview: -## Tests And Builds +```bash +npm --prefix frontend run dev +``` + +The browser preview intentionally cannot execute local Agents because it has no Electron bridge. + +
+Development checks ```bash npm --prefix frontend test @@ -105,19 +157,26 @@ npm --prefix desktop run pack Generated packages are written to `desktop/dist/` and are not committed. -## Repository Layout +
-```text -frontend/ Vue renderer and product assets -desktop/ Electron main process, preload bridge, Agent runtime, and tests -documentation/ Architecture, security, workflows, and launch material -.github/ Continuous integration workflows -``` +
+Architecture and security boundaries -The architecture review entry point is [`documentation/architecture.md`](documentation/architecture.md). Draft launch messaging lives in [`documentation/launch-kit.md`](documentation/launch-kit.md). +The Vue renderer reaches Agent functionality only through validated preload APIs. The Electron main process owns filesystem paths, child processes, Agent configuration, persistence, and secure credential storage. The renderer has no unrestricted shell API. -## Release Status +New conversations currently allow workspace writes by default; users can disable write mode per conversation. Adapter-level read-only modes reduce accidental writes where upstream CLIs can enforce them, but they are not an operating-system sandbox. Guided installers use fixed allowlisted recipes; upstream packages and scripts are not yet digest- or signature-pinned. -Meldwork is under active development. Local discovery, persistent direct/group conversations, guarded heterogeneous-Agent execution, automatic discussion, `@` Skill references, image attachments, provider storage, frontend tests, desktop tests, and macOS packaging configuration are implemented. +Internal identifiers such as `window.roundrelayDesktop`, existing storage filenames, and `com.roundrelay.desktop` are intentionally retained during the brand transition to preserve the current local-data and preload contracts. -Before a public open-source release, the project still needs a selected license, a target-device Agent compatibility matrix, and signed/notarized macOS distribution. Windows support should remain implementation-level until it passes a real-device release matrix. +
+ +## Project status + +Meldwork is a market-validation MVP, not a finished platform. Before a public release, the project still needs: + +- a selected and committed software license; +- target-device compatibility tests for supported Agents; +- a public connector contract before "any Agent" is presented as shipped capability; +- signed and notarized macOS distribution; +- real-device Windows validation before Windows support is marketed as production-ready; +- product validation that persistent cross-Agent work creates enough value to justify a new workspace. diff --git a/desktop/src/local-workspace.cjs b/desktop/src/local-workspace.cjs index 49ca52c..2b833d9 100644 --- a/desktop/src/local-workspace.cjs +++ b/desktop/src/local-workspace.cjs @@ -521,6 +521,9 @@ class LocalWorkspace extends EventEmitter { const runtimeMissing = runtime?.credentialState === 'missing' const verifiedReady = runtime?.credentialState === 'ready' || this.state.messages.some(message => message.role === 'agent' && message.agentKind === agent.kind) + const nativeReadySource = nativeState === 'ready' + ? (native.source || 'native-credential') + : '' const credentialState = sharedProviderReady ? 'ready' : sharedProviderRequired @@ -541,13 +544,13 @@ class LocalWorkspace extends EventEmitter { installed: true, credentialState, availabilitySource: sharedProviderReady - ? 'shared-provider' + ? (!runtimeMissing && nativeReadySource ? nativeReadySource : 'shared-provider') : runtimeMissing ? 'runtime-auth-failure' : nativeState === 'missing' ? (native.source || 'none') - : nativeState === 'ready' - ? (native.source || 'native-credential') + : nativeReadySource + ? nativeReadySource : verifiedReady ? 'verified-run' : 'unverified', diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index d689b80..a6add81 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -182,6 +182,9 @@ const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({ }) function providerPresetFromStatus(kind, status = {}) { + if (['official', 'openrouter', 'custom'].includes(status.activePreset)) { + return status.activePreset + } const provider = String(status.provider || '').trim().toLowerCase() const baseUrl = String(status.baseUrl || '').trim().replace(/\/+$/, '') if (/openrouter/i.test(provider) || /openrouter\.ai$/i.test(new URL(baseUrl || 'https://example.com').hostname)) { @@ -288,10 +291,7 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) { function providerOptions(kind, context = {}) { if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {} const status = providerStore?.status(kind) || {} - if (!status.configured) { - if (kind === 'openclaw') throw new Error('PROVIDER_CREDENTIAL_REQUIRED') - return {} - } + if (!status.configured) return {} return providerOptionsFor( kind, providerStore.envForAgent(kind), @@ -577,12 +577,9 @@ function createWorkspace() { importAgentOutputs: input => importAgentOutputs(input, availableAttachmentStore()), validateSkillSelections: (kind, selections) => skillCatalog.validateSelections(kind, selections), imageAttachmentLimit, - credentialState: (kind, agent) => { - if (kind === 'openclaw' && !providerStore?.status(kind).configured) { - return { state: 'missing', source: 'agent-provider-required' } - } - return resolveNativeCredentialState(kind, { executable: agent?.executable }) - }, + credentialState: (kind, agent) => ( + resolveNativeCredentialState(kind, { executable: agent?.executable }) + ), sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured, runAgent: async (agent, prompt, workdir, options = {}) => { const injected = providerOptions(agent.kind, { @@ -649,7 +646,7 @@ function createWindow() { minWidth: 980, minHeight: 680, title: 'Meldwork', - backgroundColor: '#f6f3ed', + backgroundColor: '#f3f6f8', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, @@ -799,12 +796,18 @@ function registerIpc() { provider: input?.provider, baseUrl: input?.baseUrl, model: input?.model, + preset: input?.preset, }) await refreshLocalAgentState() return result }) - registerTrustedHandle('local-agent-provider:delete', async (kind) => { - const result = providerStore.delete(providerAgentKind(kind)) + registerTrustedHandle('local-agent-provider:activate', async (kind, preset) => { + const result = providerStore.activate(providerAgentKind(kind), preset) + await refreshLocalAgentState() + return result + }) + registerTrustedHandle('local-agent-provider:delete', async (kind, preset) => { + const result = providerStore.delete(providerAgentKind(kind), preset) await refreshLocalAgentState() return result }) diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index e30b53b..9ee157b 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -95,7 +95,8 @@ if (isLocalDocument) Object.assign(desktopApi, { status: kind => ipcRenderer.invoke('local-agent-provider:status', kind), probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind), save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input), - delete: kind => ipcRenderer.invoke('local-agent-provider:delete', kind), + activate: (kind, preset) => ipcRenderer.invoke('local-agent-provider:activate', kind, preset), + delete: (kind, preset) => ipcRenderer.invoke('local-agent-provider:delete', kind, preset), }), localKnowledgeBase: Object.freeze({ status: () => ipcRenderer.invoke('local-knowledge-base:status'), diff --git a/desktop/src/provider-store.cjs b/desktop/src/provider-store.cjs index 2507634..1cbbb9d 100644 --- a/desktop/src/provider-store.cjs +++ b/desktop/src/provider-store.cjs @@ -2,9 +2,11 @@ const fs = require('node:fs') const { atomicWritePrivateFile } = require('./private-file.cjs') -const STORE_VERSION = 2 +const STORE_VERSION = 3 +const SINGLE_PROFILE_STORE_VERSION = 2 const LEGACY_STORE_VERSION = 1 const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/ +const PROVIDER_PRESETS = new Set(['official', 'openrouter', 'custom']) const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' }) function normalizeMetadata(input) { @@ -33,6 +35,18 @@ function sortedKeys(value) { : '' } +function inferStoredPreset(entry) { + const provider = String(entry?.provider || '').toLowerCase() + const baseUrl = String(entry?.baseUrl || '') + return /openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl)) + ? 'openrouter' + : 'custom' +} + +function hostname(value) { + try { return new URL(value).hostname } catch { return '' } +} + class ProviderStore { constructor({ storagePath, safeStorage, allowedKinds = [] }) { if (typeof storagePath !== 'string' || !storagePath) { @@ -63,6 +77,8 @@ class ProviderStore { provider: '', baseUrl: '', model: '', + activePreset: 'official', + profiles: {}, encryptionAvailable: null, configured: false, } @@ -71,13 +87,29 @@ class ProviderStore { const encryptionAvailable = this.encryptionAvailable() let metadata = EMPTY_METADATA let configured = false + let activePreset = 'official' + const profiles = {} if (encryptionAvailable && credentialStored) { try { - const entry = this.readDocument().agents[targetKind] - if (entry) { - this.decryptEntry(entry) - metadata = entry - configured = true + const agent = this.readDocument().agents[targetKind] + if (agent) { + activePreset = agent.activePreset + for (const [preset, entry] of Object.entries(agent.profiles)) { + try { + this.decryptEntry(entry) + profiles[preset] = { + provider: entry.provider, + baseUrl: entry.baseUrl, + model: entry.model, + configured: true, + } + } catch { /* keep an unreadable profile unavailable */ } + } + const activeEntry = profiles[activePreset] + if (activeEntry) { + metadata = activeEntry + configured = true + } } } catch { /* unreadable credentials stay unavailable */ } } @@ -85,6 +117,8 @@ class ProviderStore { provider: metadata.provider, baseUrl: metadata.baseUrl, model: metadata.model, + activePreset, + profiles, encryptionAvailable, configured, } @@ -92,7 +126,7 @@ class ProviderStore { save(kind, input) { const targetKind = this.normalizeKind(kind) - if (sortedKeys(input) !== 'apiKey,baseUrl,model,provider' + if (sortedKeys(input) !== 'apiKey,baseUrl,model,preset,provider' || typeof input.apiKey !== 'string' || !input.apiKey.trim() || input.apiKey.trim().length > 8192) { throw new Error('PROVIDER_INVALID_CREDENTIAL') @@ -100,6 +134,7 @@ class ProviderStore { this.requireEncryption() const apiKey = input.apiKey.trim() + const preset = this.normalizePreset(input.preset) const metadata = normalizeMetadata(input) let encrypted try { @@ -115,19 +150,53 @@ class ProviderStore { const document = fs.existsSync(this.storagePath) ? this.readDocument() : { version: STORE_VERSION, agents: {} } - document.agents[targetKind] = { + const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} } + agent.profiles[preset] = { ...metadata, encrypted: encrypted.toString('base64'), } + agent.activePreset = preset + document.agents[targetKind] = agent atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) return this.status(targetKind) } - delete(kind) { + activate(kind, preset) { + const targetKind = this.normalizeKind(kind) + const targetPreset = this.normalizePreset(preset) + if (!fs.existsSync(this.storagePath)) { + if (targetPreset !== 'official') throw new Error('PROVIDER_PROFILE_UNAVAILABLE') + return this.status(targetKind, { probeEncryption: true }) + } + this.requireEncryption() + const document = this.readDocument() + const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} } + if (targetPreset !== 'official' && !agent.profiles[targetPreset]) { + throw new Error('PROVIDER_PROFILE_UNAVAILABLE') + } + if (agent.profiles[targetPreset]) this.decryptEntry(agent.profiles[targetPreset]) + agent.activePreset = targetPreset + document.agents[targetKind] = agent + atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) + return this.status(targetKind) + } + + delete(kind, preset = '') { const targetKind = this.normalizeKind(kind) if (fs.existsSync(this.storagePath)) { const document = this.readDocument() - delete document.agents[targetKind] + if (preset) { + const targetPreset = this.normalizePreset(preset) + const agent = document.agents[targetKind] + if (agent) { + delete agent.profiles[targetPreset] + if (agent.activePreset === targetPreset) agent.activePreset = 'official' + if (Object.keys(agent.profiles).length) document.agents[targetKind] = agent + else delete document.agents[targetKind] + } + } else { + delete document.agents[targetKind] + } if (Object.keys(document.agents).length) { atomicWritePrivateFile(this.storagePath, JSON.stringify(document)) } else { @@ -140,7 +209,8 @@ class ProviderStore { envForAgent(kind) { const targetKind = this.normalizeKind(kind) this.requireEncryption() - const entry = this.readDocument().agents[targetKind] + const agent = this.readDocument().agents[targetKind] + const entry = agent?.profiles?.[agent.activePreset] if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') return { OPENAI_API_KEY: this.decryptEntry(entry), @@ -161,6 +231,12 @@ class ProviderStore { return normalized } + normalizePreset(preset) { + const normalized = String(preset || '').trim().toLowerCase() + if (!PROVIDER_PRESETS.has(normalized)) throw new Error('PROVIDER_PRESET_UNSUPPORTED') + return normalized + } + decryptEntry(entry) { this.requireEncryption() try { @@ -194,11 +270,30 @@ class ProviderStore { model: payload.model, encrypted: payload.encrypted, }) + const preset = inferStoredPreset(entry) return { version: STORE_VERSION, - agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, { ...entry }])), + agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, { + activePreset: preset, + profiles: { [preset]: { ...entry } }, + }])), } } + if (payload?.version === SINGLE_PROFILE_STORE_VERSION) { + if (sortedKeys(payload) !== 'agents,version' + || !payload.agents || typeof payload.agents !== 'object' + || Array.isArray(payload.agents)) { + throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') + } + const agents = {} + for (const [kind, rawEntry] of Object.entries(payload.agents)) { + if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') + const entry = this.normalizeEntry(rawEntry) + const preset = inferStoredPreset(entry) + agents[kind] = { activePreset: preset, profiles: { [preset]: entry } } + } + return { version: STORE_VERSION, agents } + } if (sortedKeys(payload) !== 'agents,version' || payload.version !== STORE_VERSION || !payload.agents || typeof payload.agents !== 'object' @@ -208,11 +303,33 @@ class ProviderStore { const agents = {} for (const [kind, entry] of Object.entries(payload.agents)) { if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') - agents[kind] = this.normalizeEntry(entry) + agents[kind] = this.normalizeAgentEntry(entry) } return { version: STORE_VERSION, agents } } + normalizeAgentEntry(entry) { + try { + if (sortedKeys(entry) !== 'activePreset,profiles' + || !entry.profiles || typeof entry.profiles !== 'object' + || Array.isArray(entry.profiles)) { + throw new Error('invalid agent entry') + } + const activePreset = this.normalizePreset(entry.activePreset) + const profiles = {} + for (const [preset, profile] of Object.entries(entry.profiles)) { + const normalizedPreset = this.normalizePreset(preset) + profiles[normalizedPreset] = this.normalizeEntry(profile) + } + if (activePreset !== 'official' && !profiles[activePreset]) { + throw new Error('missing active profile') + } + return { activePreset, profiles } + } catch { + throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE') + } + } + normalizeEntry(entry) { if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider' || typeof entry.encrypted !== 'string') { diff --git a/desktop/test/local-workspace.test.cjs b/desktop/test/local-workspace.test.cjs index 336d3b2..ac97060 100644 --- a/desktop/test/local-workspace.test.cjs +++ b/desktop/test/local-workspace.test.cjs @@ -85,6 +85,30 @@ test('installed Agents distinguish ready, unverified, and missing credential sta assert.equal(readinessAgents.find(agent => agent.kind === 'kimi').executable, '/tmp/kimi') }) +test('native readiness remains visible while a Meldwork Provider profile is active', async (t) => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + let codexNativeState = { state: 'ready', source: 'native-auth-status' } + options.credentialState = async kind => kind === 'codex' + ? codexNativeState + : { state: 'ready', source: 'native-credential' } + options.sharedProviderReady = kind => kind === 'codex' + const workspace = new LocalWorkspace(options) + + let snapshot = await workspace.refreshAgents() + let codex = snapshot.agents.find(agent => agent.kind === 'codex') + assert.equal(codex.credentialState, 'ready') + assert.equal(codex.availabilitySource, 'native-auth-status') + assert.equal(codex.available, true) + + codexNativeState = { state: 'missing', source: 'native-auth-status' } + snapshot = await workspace.refreshAgents() + codex = snapshot.agents.find(agent => agent.kind === 'codex') + assert.equal(codex.credentialState, 'ready') + assert.equal(codex.availabilitySource, 'shared-provider') + assert.equal(codex.available, true) +}) + test('an unverified installed Agent stays unavailable until credentials are detected', async (t) => { const { directory, calls, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/main-security.test.cjs b/desktop/test/main-security.test.cjs index b5c40c6..8c51313 100644 --- a/desktop/test/main-security.test.cjs +++ b/desktop/test/main-security.test.cjs @@ -50,6 +50,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([ 'local-agent-provider:status', 'local-agent-provider:probe', 'local-agent-provider:save', + 'local-agent-provider:activate', 'local-agent-provider:delete', 'local-knowledge-base:status', 'local-knowledge-base:open-guide', @@ -104,6 +105,7 @@ function loadMain(userData, options = {}) { let singleInstanceLockCalls = 0 let attachmentConstructionCount = 0 const providerConfiguredKinds = new Set(options.providerConfigured === true ? ['hermes'] : []) + const providerActivePresets = new Map(options.providerConfigured === true ? [['hermes', 'custom']] : []) class TestWorkspace extends EventEmitter { constructor(input) { @@ -278,6 +280,7 @@ function loadMain(userData, options = {}) { constructor(input) { this.input = input this.saved = [] + this.activated = [] this.deleted = [] this.statusCalls = [] providerInstances.push(this) @@ -286,22 +289,40 @@ function loadMain(userData, options = {}) { status(kind, input) { this.statusCalls.push({ kind, input }) return providerConfiguredKinds.has(kind) - ? { ...PROVIDER_METADATA, encryptionAvailable: true, configured: true } + ? { + ...PROVIDER_METADATA, + activePreset: providerActivePresets.get(kind) || 'custom', + profiles: { + [providerActivePresets.get(kind) || 'custom']: { ...PROVIDER_METADATA, configured: true }, + }, + encryptionAvailable: true, + configured: true, + } : { provider: '', baseUrl: '', model: '', - encryptionAvailable: true, configured: false, + activePreset: 'official', profiles: {}, encryptionAvailable: true, configured: false, } } save(kind, input) { this.saved.push({ kind, input }) providerConfiguredKinds.add(kind) + providerActivePresets.set(kind, input.preset) return this.status(kind) } - delete(kind) { - this.deleted.push(kind) + activate(kind, preset) { + this.activated.push({ kind, preset }) + providerActivePresets.set(kind, preset) + if (preset === 'official') providerConfiguredKinds.delete(kind) + else providerConfiguredKinds.add(kind) + return this.status(kind) + } + + delete(kind, preset) { + this.deleted.push({ kind, preset }) providerConfiguredKinds.delete(kind) + providerActivePresets.set(kind, 'official') return this.status(kind) } @@ -955,7 +976,7 @@ test('Provider IPC accepts the complete user payload and supports local deletion t.after(() => fs.rmSync(directory, { recursive: true, force: true })) const { harness } = loadMain(directory) await harness.ready() - const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key' } + const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key', preset: 'custom' } const initial = await harness.ipcHandlers.get('local-agent-provider:status')( harness.event(), 'hermes', @@ -976,17 +997,24 @@ test('Provider IPC accepts the complete user payload and supports local deletion assert.equal(saved.configured, true) assert.equal(harness.workspaceInstances[0].refreshCount, 1) + const activated = await harness.ipcHandlers.get('local-agent-provider:activate')( + harness.event(), 'hermes', 'custom', + ) + assert.deepEqual(harness.providerInstances[0].activated, [{ kind: 'hermes', preset: 'custom' }]) + assert.equal(activated.activePreset, 'custom') + assert.equal(harness.workspaceInstances[0].refreshCount, 2) + await assert.rejects( async () => harness.ipcHandlers.get('local-agent-provider:status')(harness.event(), 'not-an-agent'), { message: 'PROVIDER_AGENT_UNSUPPORTED' }, ) const deleted = await harness.ipcHandlers.get('local-agent-provider:delete')( - harness.event(), 'hermes', + harness.event(), 'hermes', 'custom', ) - assert.deepEqual(harness.providerInstances[0].deleted, ['hermes']) + assert.deepEqual(harness.providerInstances[0].deleted, [{ kind: 'hermes', preset: 'custom' }]) assert.equal(deleted.configured, false) - assert.equal(harness.workspaceInstances[0].refreshCount, 2) + assert.equal(harness.workspaceInstances[0].refreshCount, 3) }) test('Knowledge base IPC exposes status, safe guides, and a local Obsidian directory picker', async (t) => { @@ -1261,6 +1289,44 @@ test('configured Provider is injected only through local Agent execution options assert.equal(options.env.CURRENT_RUN, '1') }) +test('OpenClaw keeps native readiness and execution when no Meldwork Provider profile is active', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-native-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const credentialChecks = [] + const { harness } = loadMain(directory, { + moduleMocks: { + './local-agent-readiness.cjs': { + nativeCredentialEnvironment: kind => kind === 'openclaw' + ? { OPENCLAW_NATIVE_CONFIG: 'available' } + : {}, + resolveNativeCredentialState: async (kind, input) => { + credentialChecks.push({ kind, input }) + return { state: 'ready', source: 'native-credential' } + }, + }, + }, + }) + await harness.ready() + const workspace = harness.workspaceInstances[0] + + const readiness = await workspace.input.credentialState('openclaw', { + executable: '/tmp/openclaw', + }) + assert.deepEqual(readiness, { state: 'ready', source: 'native-credential' }) + assert.deepEqual(credentialChecks, [{ + kind: 'openclaw', + input: { executable: '/tmp/openclaw' }, + }]) + + await workspace.input.runAgent( + { kind: 'openclaw', executable: '/tmp/openclaw' }, + 'hello', directory, { sessionRef: 'agent:main:desktop-native-openclaw' }, + ) + const options = harness.runAgentCalls[0][3] + assert.equal(options.env.OPENCLAW_NATIVE_CONFIG, 'available') + assert.equal(Object.hasOwn(options.env, 'MANAGED_OPENCLAW'), false) +}) + test('manual Agent refreshes remain serialized', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-refresh-')) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/desktop/test/preload-security.test.cjs b/desktop/test/preload-security.test.cjs index 94a3363..406893c 100644 --- a/desktop/test/preload-security.test.cjs +++ b/desktop/test/preload-security.test.cjs @@ -70,7 +70,7 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me assert.equal('cancelConfigure' in api.localWorkspace, false) assert.deepEqual( Object.keys(api.localAgentProvider).sort(), - ['delete', 'probe', 'save', 'status'], + ['activate', 'delete', 'probe', 'save', 'status'], ) assert.equal('envForAgent' in api.localAgentProvider, false) assert.equal('read' in api.localAgentProvider, false) @@ -79,17 +79,19 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me await api.localAgentProvider.probe('hermes') await api.localAgentProvider.save('hermes', { provider: 'Example', baseUrl: 'https://api.example.com/v1', - model: 'example-model', apiKey: 'test-renderer-key', + model: 'example-model', apiKey: 'test-renderer-key', preset: 'custom', }) - await api.localAgentProvider.delete('hermes') + await api.localAgentProvider.activate('hermes', 'custom') + await api.localAgentProvider.delete('hermes', 'custom') assert.deepEqual(invocations.map(call => call.channel), [ 'local-agent-provider:status', 'local-agent-provider:probe', 'local-agent-provider:save', + 'local-agent-provider:activate', 'local-agent-provider:delete', ]) assert.deepEqual(invocations.map(call => call.args[0]), [ - 'hermes', 'hermes', 'hermes', 'hermes', + 'hermes', 'hermes', 'hermes', 'hermes', 'hermes', ]) }) diff --git a/desktop/test/provider-store.test.cjs b/desktop/test/provider-store.test.cjs index b8e1261..34ad7b5 100644 --- a/desktop/test/provider-store.test.cjs +++ b/desktop/test/provider-store.test.cjs @@ -10,9 +10,31 @@ const PROVIDER = Object.freeze({ baseUrl: 'https://api.example.com/v1', model: 'example-model', }) +const OPENROUTER_PROVIDER = Object.freeze({ + provider: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'openrouter/example-model', +}) -function credential(apiKey = 'test-provider-key', metadata = PROVIDER) { - return { ...metadata, apiKey } +function credential(apiKey = 'test-provider-key', metadata = PROVIDER, preset = 'custom') { + return { ...metadata, apiKey, preset } +} + +function emptyStatus(encryptionAvailable) { + return { + provider: '', baseUrl: '', model: '', activePreset: 'official', profiles: {}, + encryptionAvailable, configured: false, + } +} + +function configuredStatus(metadata = PROVIDER, preset = 'custom') { + return { + ...metadata, + activePreset: preset, + profiles: { [preset]: { ...metadata, configured: true } }, + encryptionAvailable: true, + configured: true, + } } function encryptedSafeStorage(available = true) { @@ -37,13 +59,11 @@ test('status exposes user-configured metadata without the API key', (t) => { const { directory, store } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - assert.deepEqual(store.status('hermes'), { - provider: '', baseUrl: '', model: '', encryptionAvailable: null, configured: false, - }) + assert.deepEqual(store.status('hermes'), emptyStatus(null)) store.save('hermes', credential()) const status = store.status('hermes') - assert.deepEqual(status, { ...PROVIDER, encryptionAvailable: true, configured: true }) + assert.deepEqual(status, configuredStatus()) assert.equal('apiKey' in status, false) }) @@ -68,9 +88,7 @@ test('save encrypts the API key, writes atomically, and restricts file permissio t.after(() => fs.rmSync(directory, { recursive: true, force: true })) const apiKey = 'test-key-never-plaintext' - assert.deepEqual(store.save('hermes', credential(apiKey)), { - ...PROVIDER, encryptionAvailable: true, configured: true, - }) + assert.deepEqual(store.save('hermes', credential(apiKey)), configuredStatus()) const stored = fs.readFileSync(storagePath, 'utf8') assert.equal(stored.includes(apiKey), false) assert.equal(fs.statSync(storagePath).mode & 0o777, 0o600) @@ -80,7 +98,7 @@ test('save encrypts the API key, writes atomically, and restricts file permissio ) }) -test('save accepts only the complete four-field Provider payload', (t) => { +test('save accepts only the complete five-field Provider payload', (t) => { const { directory, store } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) @@ -91,9 +109,15 @@ test('save accepts only the complete four-field Provider payload', (t) => { { message: 'PROVIDER_INVALID_CREDENTIAL' }, ) assert.throws( - () => store.save('hermes', { apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model' }), + () => store.save('hermes', { + apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model', preset: 'custom', + }), { message: 'PROVIDER_INVALID_METADATA' }, ) + assert.throws( + () => store.save('hermes', credential('key', PROVIDER, 'unknown')), + { message: 'PROVIDER_PRESET_UNSUPPORTED' }, + ) }) test('envForAgent decrypts the configured Provider', (t) => { @@ -108,13 +132,47 @@ test('envForAgent decrypts the configured Provider', (t) => { }) }) +test('multiple Provider profiles stay encrypted and can be activated independently', (t) => { + const { directory, store } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + + store.save('hermes', credential('openrouter-key', OPENROUTER_PROVIDER, 'openrouter')) + store.save('hermes', credential('custom-key', PROVIDER, 'custom')) + + assert.deepEqual(store.status('hermes'), { + ...PROVIDER, + activePreset: 'custom', + profiles: { + openrouter: { ...OPENROUTER_PROVIDER, configured: true }, + custom: { ...PROVIDER, configured: true }, + }, + encryptionAvailable: true, + configured: true, + }) + + store.activate('hermes', 'openrouter') + assert.equal(store.status('hermes').activePreset, 'openrouter') + assert.deepEqual(store.envForAgent('hermes'), { + OPENAI_API_KEY: 'openrouter-key', + OPENAI_BASE_URL: OPENROUTER_PROVIDER.baseUrl, + OPENAI_MODEL: OPENROUTER_PROVIDER.model, + }) + + const afterDelete = store.delete('hermes', 'openrouter') + assert.equal(afterDelete.activePreset, 'official') + assert.equal(afterDelete.configured, false) + assert.deepEqual(afterDelete.profiles, { + custom: { ...PROVIDER, configured: true }, + }) + store.activate('hermes', 'custom') + assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'custom-key') +}) + test('encryption unavailability fails closed for status, save, and reads', (t) => { const { directory, storagePath, store } = fixture(encryptedSafeStorage(false)) t.after(() => fs.rmSync(directory, { recursive: true, force: true })) - assert.deepEqual(store.status('hermes', { probeEncryption: true }), { - provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false, - }) + assert.deepEqual(store.status('hermes', { probeEncryption: true }), emptyStatus(false)) assert.throws(() => store.save('hermes', credential()), { message: 'PROVIDER_ENCRYPTION_UNAVAILABLE', }) @@ -145,9 +203,7 @@ test('corrupt credentials stay unavailable and are never returned', (t) => { mode: 0o600, }) - assert.deepEqual(store.status('hermes'), { - provider: '', baseUrl: '', model: '', encryptionAvailable: true, configured: false, - }) + assert.deepEqual(store.status('hermes'), emptyStatus(true)) assert.throws(() => store.envForAgent('hermes'), { message: 'PROVIDER_CREDENTIAL_UNAVAILABLE', }) @@ -160,9 +216,7 @@ test('delete removes the local credential even when encryption is unavailable', store.save('hermes', credential()) safeStorage.isEncryptionAvailable = () => false - assert.deepEqual(store.delete('hermes'), { - provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false, - }) + assert.deepEqual(store.delete('hermes'), emptyStatus(false)) assert.equal(fs.existsSync(storagePath), false) assert.doesNotThrow(() => store.delete('hermes')) }) @@ -216,12 +270,48 @@ test('legacy shared credentials migrate into independent Agent entries on mutati store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'new-qwen-model' })) const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8')) - assert.equal(migrated.version, 2) + assert.equal(migrated.version, 3) assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'qwen']) assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key') assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key') }) +test('single-profile Agent credentials migrate to multi-profile storage on mutation', (t) => { + const { directory, storagePath, store } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const encrypted = encryptedSafeStorage().encryptString('stored-openrouter-key').toString('base64') + fs.writeFileSync(storagePath, JSON.stringify({ + version: 2, + agents: { + hermes: { ...OPENROUTER_PROVIDER, encrypted }, + }, + }), { mode: 0o600 }) + + assert.equal(store.status('hermes').activePreset, 'openrouter') + assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'stored-openrouter-key') + + store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'qwen-model' })) + const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8')) + assert.equal(migrated.version, 3) + assert.equal(migrated.agents.hermes.activePreset, 'openrouter') + assert.equal(migrated.agents.qwen.activePreset, 'custom') +}) + +test('malformed multi-profile documents fail as unavailable credentials', (t) => { + const { directory, storagePath, store } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + fs.writeFileSync(storagePath, JSON.stringify({ + version: 3, + agents: { + hermes: { activePreset: 'unknown', profiles: {} }, + }, + }), { mode: 0o600 }) + + assert.throws(() => store.envForAgent('hermes'), { + message: 'PROVIDER_CREDENTIAL_UNAVAILABLE', + }) +}) + test('Provider operations reject unsupported Agent kinds', (t) => { const { directory, store } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true })) diff --git a/frontend/public/knowledge-base-logos/dingtalk.svg b/frontend/public/knowledge-base-logos/dingtalk.svg index c0d8f7e..caa1bc5 100644 --- a/frontend/public/knowledge-base-logos/dingtalk.svg +++ b/frontend/public/knowledge-base-logos/dingtalk.svg @@ -1,4 +1,5 @@ + DingTalk diff --git a/frontend/public/knowledge-base-logos/feishu.svg b/frontend/public/knowledge-base-logos/feishu.svg index 452696e..3eb29ad 100644 --- a/frontend/public/knowledge-base-logos/feishu.svg +++ b/frontend/public/knowledge-base-logos/feishu.svg @@ -1,4 +1,5 @@ + Feishu diff --git a/frontend/public/knowledge-base-logos/obsidian.svg b/frontend/public/knowledge-base-logos/obsidian.svg index 540b30e..fc0072f 100644 --- a/frontend/public/knowledge-base-logos/obsidian.svg +++ b/frontend/public/knowledge-base-logos/obsidian.svg @@ -1,4 +1,5 @@ + Obsidian diff --git a/frontend/public/logos/meldwork-app-v2.svg b/frontend/public/logos/meldwork-app-v2.svg deleted file mode 100644 index 907d4ff..0000000 --- a/frontend/public/logos/meldwork-app-v2.svg +++ /dev/null @@ -1,14 +0,0 @@ - - Meldwork app icon - - - - - - diff --git a/frontend/public/logos/meldwork-app-v3.svg b/frontend/public/logos/meldwork-app-v3.svg new file mode 100644 index 0000000..f51533a --- /dev/null +++ b/frontend/public/logos/meldwork-app-v3.svg @@ -0,0 +1,15 @@ + + Meldwork Trace app icon + A white asymmetric work line ending before a separate coral acceptance point on a carbon background. + + + + + + diff --git a/frontend/public/logos/meldwork-app.png b/frontend/public/logos/meldwork-app.png index 6833a2a..39eece5 100644 Binary files a/frontend/public/logos/meldwork-app.png and b/frontend/public/logos/meldwork-app.png differ diff --git a/frontend/public/logos/meldwork-app.svg b/frontend/public/logos/meldwork-app.svg index fe8b4e5..f51533a 100644 --- a/frontend/public/logos/meldwork-app.svg +++ b/frontend/public/logos/meldwork-app.svg @@ -1,18 +1,15 @@ - - - - - + + Meldwork Trace app icon + A white asymmetric work line ending before a separate coral acceptance point on a carbon background. + + + + + diff --git a/frontend/public/logos/meldwork-brand-board-v2.svg b/frontend/public/logos/meldwork-brand-board-v2.svg deleted file mode 100644 index 4824370..0000000 --- a/frontend/public/logos/meldwork-brand-board-v2.svg +++ /dev/null @@ -1,49 +0,0 @@ - - Meldwork Open Stitch brand board - A five-panel preview of the Meldwork V2 mark, wordmark, app icon, construction, color system, and slogan. - - - - - - - - Meldwork - OPEN STITCH / V2 CANDIDATE - - - - - - - Different agents. - Work you can verify. - - - - - - - - APP ICON - - - - - - - - - - ASYMMETRIC / OPEN / CONTINUOUS - - - - - - PAPER - CORAL - CARBON - Evidence before acceptance. - The endpoint marks verification, not consensus. - diff --git a/frontend/public/logos/meldwork-brand-board-v3.svg b/frontend/public/logos/meldwork-brand-board-v3.svg new file mode 100644 index 0000000..fc0d2eb --- /dev/null +++ b/frontend/public/logos/meldwork-brand-board-v3.svg @@ -0,0 +1,172 @@ + + Meldwork Trace V3 brand board + A brand-system board showing the V3 mark, wordmark, app icon, navigation use, supported Agent field, README application, construction logic, and palette. + + + + + + + + + + + + + + + + + + 01 / WORDMARK + + + + TRACE / V3 + Continuous Agent work. Separate human acceptance. + + + + 02 / DARK APPLICATION + + + + Meldwork + Agents change. + Work continues. + + + + + Persistent workspace for general agents + + LOCAL-FIRST + + + + 03 / AGENT FIELD + + + + + + + + + + + + + + + + + + + + + + + + + + + + Codex + + + + + Claude + + + + + Kimi + + + + + MiMo + + + + + WorkBuddy + + + + + Gemini + + + + + OpenCode + + + + + Qwen + + + + + Hermes + + + + + OpenClaw + + + + + + 04 / APP + NAV + + + + + + + + + Light navigation + + + + + Dark navigation + + + + 05 / TRACE + ACCEPTANCE + + + + + AGENT TRACE + + REVIEW GAP + + ACCEPTANCE + + + + + + INK / PAPER / CORAL / CARBON + + MELDWORK / TRACE V3 / IDENTITY SYSTEM + 03 + diff --git a/frontend/public/logos/meldwork-favicon-16.png b/frontend/public/logos/meldwork-favicon-16.png index d2c71d4..b174514 100644 Binary files a/frontend/public/logos/meldwork-favicon-16.png and b/frontend/public/logos/meldwork-favicon-16.png differ diff --git a/frontend/public/logos/meldwork-favicon-32.png b/frontend/public/logos/meldwork-favicon-32.png index 147cfd3..811ec9e 100644 Binary files a/frontend/public/logos/meldwork-favicon-32.png and b/frontend/public/logos/meldwork-favicon-32.png differ diff --git a/frontend/public/logos/meldwork-favicon.png b/frontend/public/logos/meldwork-favicon.png index a1647ff..feb8492 100644 Binary files a/frontend/public/logos/meldwork-favicon.png and b/frontend/public/logos/meldwork-favicon.png differ diff --git a/frontend/public/logos/meldwork-mark-v2-dark.svg b/frontend/public/logos/meldwork-mark-v2-dark.svg deleted file mode 100644 index 0f5f5b9..0000000 --- a/frontend/public/logos/meldwork-mark-v2-dark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - Meldwork Open Stitch mark for dark backgrounds - - - diff --git a/frontend/public/logos/meldwork-mark-v2.svg b/frontend/public/logos/meldwork-mark-v2.svg deleted file mode 100644 index dd91474..0000000 --- a/frontend/public/logos/meldwork-mark-v2.svg +++ /dev/null @@ -1,11 +0,0 @@ - - Meldwork Open Stitch mark - - - diff --git a/frontend/public/logos/meldwork-mark.svg b/frontend/public/logos/meldwork-mark.svg index 32ad561..d2340a8 100644 --- a/frontend/public/logos/meldwork-mark.svg +++ b/frontend/public/logos/meldwork-mark.svg @@ -1,22 +1,12 @@ - + + Meldwork Trace mark + An asymmetric continuous work line ending before a separate coral acceptance point. - - + diff --git a/frontend/public/logos/meldwork-readme-banner-en.png b/frontend/public/logos/meldwork-readme-banner-en.png new file mode 100644 index 0000000..943aaf7 Binary files /dev/null and b/frontend/public/logos/meldwork-readme-banner-en.png differ diff --git a/frontend/public/logos/meldwork-readme-banner-zh-CN.png b/frontend/public/logos/meldwork-readme-banner-zh-CN.png new file mode 100644 index 0000000..cb399e9 Binary files /dev/null and b/frontend/public/logos/meldwork-readme-banner-zh-CN.png differ diff --git a/frontend/public/logos/meldwork-wordmark-v2-dark.svg b/frontend/public/logos/meldwork-wordmark-v2-dark.svg deleted file mode 100644 index 0f17844..0000000 --- a/frontend/public/logos/meldwork-wordmark-v2-dark.svg +++ /dev/null @@ -1,17 +0,0 @@ - - Meldwork wordmark for dark backgrounds - - - - - - diff --git a/frontend/public/logos/meldwork-wordmark-v3-dark.svg b/frontend/public/logos/meldwork-wordmark-v3-dark.svg new file mode 100644 index 0000000..951a797 --- /dev/null +++ b/frontend/public/logos/meldwork-wordmark-v3-dark.svg @@ -0,0 +1,17 @@ + + Meldwork Trace wordmark for dark backgrounds + The Meldwork name paired with the asymmetric Trace mark and coral acceptance point. + + + + diff --git a/frontend/public/logos/meldwork-wordmark-v2.svg b/frontend/public/logos/meldwork-wordmark-v3.svg similarity index 78% rename from frontend/public/logos/meldwork-wordmark-v2.svg rename to frontend/public/logos/meldwork-wordmark-v3.svg index 6f6ce03..cc40385 100644 --- a/frontend/public/logos/meldwork-wordmark-v2.svg +++ b/frontend/public/logos/meldwork-wordmark-v3.svg @@ -1,17 +1,17 @@ - - Meldwork wordmark + + Meldwork Trace wordmark + The Meldwork name paired with the asymmetric Trace mark and coral acceptance point. - - - + - + /> diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 51c6106..20d5dec 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,9 +22,11 @@
@@ -272,6 +274,7 @@ :class="{ active: activeView === 'settings' }" type="button" :title="t('nav.settings')" + :aria-current="activeView === 'settings' ? 'page' : undefined" @click="openSystemSettings('agents')" > @@ -449,16 +452,18 @@

{{ t('knowledgeBase.title') }}

{{ t('knowledgeBase.subtitle') }}

- +
+ + {{ knowledgeBaseLoading + ? t('knowledgeBase.status.checking') + : t('knowledgeBase.readyCount', { ready: readyKnowledgeBaseCount, total: localKnowledgeBaseEntries.length }) }} + + +
-
- {{ knowledgeBaseLoading - ? t('knowledgeBase.status.checking') - : t('knowledgeBase.readyCount', { ready: readyKnowledgeBaseCount, total: localKnowledgeBaseEntries.length }) }} -

{{ t(group.titleKey) }}

+

{{ t('knowledgeBase.group.plannedHint') }}

-
+
+
+ +
+ {{ t(`knowledgeBase.source.${source.kind}`) }} +

{{ t(`knowledgeBase.description.${source.kind}`) }}

+
+ + + {{ knowledgeBaseStatusLabel(source) }} + + +
+
+
-
- {{ t(`knowledgeBase.source.${source.kind}`) }} - - - {{ knowledgeBaseStatusLabel(source) }} - -
+ {{ t(`knowledgeBase.source.${source.kind}`) }}

{{ t(`knowledgeBase.description.${source.kind}`) }}

-
-
- - {{ tag.label }} + + + {{ knowledgeBaseStatusLabel(source) }} -
+ +
+
+
{{ fact.label }}
+
{{ fact.value }}
+
+

{{ knowledgeBaseHint(source) }}

{{ knowledgeBaseLocationLabel(source) }} @@ -512,6 +533,7 @@ > {{ knowledgeBasePrimaryActionLabel(source) }} +

@@ -540,10 +562,9 @@ @click="selectProviderAgent(agent.kind)" > - + {{ agent.label }} {{ providerStatusLabel(agent.kind) }} - {{ providerCapabilityLabel(agent) }} @@ -551,31 +572,24 @@ -
+
-
+

{{ t('provider.agentTitle', { agent: selectedProviderAgent.label }) }}

-

{{ agentDescription(selectedProviderAgent.kind) }}

- {{ providerCapabilityLabel(selectedProviderAgent) }} +
+ {{ providerStatusLabel(selectedProviderKind) }} + {{ providerCapabilityLabel(selectedProviderAgent) }} + {{ t('provider.activeProfile', { provider: providerPresetLabel(providerActivePreset) }) }} +
-
- - - - {{ providerStatusLabel(selectedProviderKind) }} -
-

{{ t('provider.agentSpecificHint') }}

-

- - {{ t('provider.nativeDetectedHint') }} -

+
- {{ t('provider.source') }} +
+ {{ t('provider.source') }} + {{ providerPresetHint(selectedProviderPreset.id) }} +
-
- - - -
- {{ t(providerNativeStatusPending - ? 'provider.nativeStatusCheckingTitle' - : providerNativeStatusError - ? 'provider.nativeStatusErrorTitle' - : providerNativeOverrideActive - ? 'provider.nativeOverrideTitle' - : 'provider.nativeTitle') }} -

{{ t(providerNativeStatusPending - ? 'provider.nativeStatusCheckingBody' - : providerNativeStatusError - ? 'provider.nativeStatusErrorBody' - : providerNativeOverrideActive - ? 'provider.nativeOverrideBody' - : 'provider.nativeBody') }}

-
-
+ +
+ {{ t('provider.unavailable') }} + +

{{ t('provider.encryptionUnavailable') }}

- - - - -
-
- {{ t('provider.configGuide') }} -

{{ t(selectedProviderProfile.docsKey) }}

-
-
-
-
{{ t('provider.configFile') }}
-
{{ selectedProviderProfile.configFile }}
-
-
-
{{ t('provider.runtimeKeys') }}
-
- {{ key }} -
-
-
-
+ +
+ + {{ selectedProviderPresetActive ? t('provider.active') : t('provider.configured') }} + + {{ t('provider.nativeReady') }} + {{ selectedProviderProfileStatus.provider }} + {{ selectedProviderProfileStatus.model }} + {{ selectedProviderProfile.configFile }} +
+ +
+ + + + +
+

{{ formError }}

- - - {{ t('provider.nativeConfiguredHint') }} - - - {{ t('provider.nativeOverrideFooterHint') }} - - - {{ t('provider.nativeStatusErrorFooterHint') }} - - - {{ t('provider.checking') }} - + @@ -689,41 +680,13 @@

{{ t('home.dashboardTitle') }}

{{ t('home.dashboardSubtitle') }}

-
- - +
+ + + {{ homeWorkspaceSummary }}
-
-
- {{ t('home.readyAgents') }} - {{ readyCount }} - {{ t('home.installedAgents', { count: installedCount }) }} -
-
- {{ t('home.connectedProviders') }} - {{ providerConfiguredCount }} - {{ t('home.providerTotal', { count: configurableProviderAgents.length }) }} -
-
- {{ t('home.knowledgeSources') }} - {{ readyKnowledgeBaseCount }} - {{ t('home.knowledgeTotal', { count: localKnowledgeBaseEntries.length }) }} -
-
- {{ t('home.conversations') }} - {{ snapshot.groups.length }} - {{ t('home.activeRuns', { count: snapshot.runningGroupIds.length }) }} -
-
-
@@ -731,15 +694,12 @@

{{ t('home.recentTitle') }}

{{ t('home.recentSubtitle') }}

-
-

{{ t('home.noReadyAgents') }}

-
- - -
@@ -847,13 +794,13 @@
- +
@@ -1011,10 +958,10 @@ -
+
{{ message.role === 'user' ? t('conversation.you') : agentLabel(message.agentKind) }} @@ -1066,7 +1013,7 @@ :aria-label="t('composer.mentionedAgents')" > - + {{ agentLabel(kind) }}
@@ -1091,7 +1038,7 @@ @@ -1151,14 +1098,14 @@ >
-
- {{ t('composer.targets') }} +
@@ -1414,7 +1360,7 @@ :aria-label="t('composer.mentionedAgents')" > - + {{ agentLabel(kind) }} + + + +