diff --git a/changelog.d/1712-project-assistant.added.md b/changelog.d/1712-project-assistant.added.md new file mode 100644 index 00000000..d0d69de7 --- /dev/null +++ b/changelog.d/1712-project-assistant.added.md @@ -0,0 +1 @@ +A Project now carries its own assistant: one hidden Bot and Session, memory private to that Project (decisions, open questions, the last digest), and a 09:00 Asia/Shanghai digest that answers what merged, what closed, what is still in progress and on whom, and the next slice, from the Project State files and the board rows only. It posts as the install's own GitHub account and never as the shared app: with no connector signed in, or on a host with more than one member, it refuses and escalates, and every write names the assistant and the login it posts for. Deleting a Project removes its assistant, its schedule and its memory ([#1712](https://github.com/ThinkFlowLab/FlyBot/issues/1712)). diff --git a/core/client.js b/core/client.js index bb5f6f5b..be80676a 100644 --- a/core/client.js +++ b/core/client.js @@ -6753,7 +6753,8 @@ var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ "attach_capability", "detach_capability", "track_links", - "project_state" + "project_state", + "project_digest" ]); var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); diff --git a/core/index.js b/core/index.js index 2cab41c5..c5538d53 100644 --- a/core/index.js +++ b/core/index.js @@ -39,6 +39,7 @@ import * as connectors from './src/host/connectors.js' import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' +import * as projectAssistant from './src/host/project-assistant.js' import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' @@ -57,7 +58,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 9951c540..6b04f4ab 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -724,7 +724,7 @@ const SILENT_REGISTRATION_TOOLS = new Set([ 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', 'list_capabilities', 'save_capability', 'delete_capability', 'attach_capability', 'detach_capability', - 'track_links', 'project_state', + 'track_links', 'project_state', 'project_digest', ]) const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 99687cf0..5884e93a 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -69,6 +69,8 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', + // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. + 'project-memory', 'project-memory-write', 'project-delete', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 60817a89..eee23cf1 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -672,6 +672,9 @@ export function install(rt) { case 'project-bind': case 'project-of': case 'project-board': + case 'project-delete': + case 'project-memory': + case 'project-memory-write': await rt.load() return ok(await rt.projectEndpoint(endpoint, body, actor)) case 'share-brief': diff --git a/core/src/host/constants.js b/core/src/host/constants.js index 3e3cec51..2df6cb97 100644 --- a/core/src/host/constants.js +++ b/core/src/host/constants.js @@ -49,7 +49,7 @@ export const AGENT_SESSION_TITLE_MAX = 80 // Desktop profile, so a restriction filters this list down to registered names. export const TEAM_TOOLS = [ 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'archive_bot', 'list_models', - 'bot_schedules', 'project_state', + 'bot_schedules', 'project_state', 'project_digest', 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js new file mode 100644 index 00000000..1325bb3f --- /dev/null +++ b/core/src/host/project-assistant.js @@ -0,0 +1,260 @@ +// The Project Assistant (#1712): one hidden Bot and Session per Project, memory private to that +// Project, and a 09:00 Asia/Shanghai digest built only from what the Project already knows — the +// Project State reader (2.4) and the board rows (2.3). Nothing here restates Project State, mints +// a second task identity or invents a number: every line the digest prints comes from a row or a +// file it was handed, and a day with nothing to say prints one line saying so. +// +// Identity is the install's own connector account (owner decision 2026-10-29: on a single-person +// install the Project owner is the person signed in, so `GET /user` is that person's own login). +// The shared Project App identity never stands in for a person: with no connector signed in, or on +// a host with more than one member where the Project's `ownerUserId` is not the host's own user, +// the assistant refuses and asks the owner instead. Every write it makes carries the name the +// account is posting for, because one shared GitHub login makes attribution text, not identity. +// +// The digest is not scheduled from inside the plugin: `project-create` puts exactly one 09:00 task +// on the assistant Session through the host's schedule path, and `project-delete` removes it with +// the Project. A missed beat is DSH's, and it posts nothing. +import { defineTool } from './tool-schema.js' +import { listProjectState } from './project-state.js' +import { output } from './tools.js' + +export const ASSISTANT_ROLE = 'Project Assistant' +export const DIGEST_ZONE = 'Asia/Shanghai' +export const DIGEST_TIME = '09:00' +export const DIGEST_TITLE = '09:00 Project digest' +export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' +export const MAX_ENTRY = 600 +export const MEMORY_KINDS = ['decision', 'question'] + +export const assistantName = project => `${ASSISTANT_ROLE} (${project.name})` + +export const assistantInstructions = project => [ + `You are the Project Assistant of "${project.name}". You speak for that Project and for no other.`, + 'Your memory is private to this Project: record decisions and open questions with project_memory_write, and never carry another Project\'s entries here.', + 'You may speak with the GitHub identity the host has signed in, and only while that identity is the Project owner\'s own. A write you make states the name you write as and the login you post for; the shared Project App identity is never used.', + 'Once a day, when the 09:00 task fires, call project_digest exactly once. It answers the four questions in order from the board rows and the Project State files, and prints one line on a day with no change.', +].join('\n\n') + +const refusal = (message, code = 'bot/invalid') => Object.assign(new Error(message), { code }) +const text = value => (typeof value === 'string' ? value.trim() : '') + +// The calendar day in the digest's own zone. A digest is idempotent per *day* rather than per +// 24 hours, so a retry after 09:00 still sees that today already has one. +export function dayIn(at, zone = DIGEST_ZONE) { + return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }) + .format(new Date(at)) +} + +const stamp = at => new Date(at).toISOString().slice(0, 10) + +// The four answers, in the order the issue names them, from rows the read model already carries: +// `source`, `column`, `assignee`, `updatedAt`. "Merged" is finished work a workflow run carried +// in (the run's binding is what put the row on this Project's board); "closed" is finished work a +// Bot or group chat owned. A board row has no blocked flag, so the third answer lists the cards +// still in progress and the assignee each one waits on — it never calls one blocked without that +// evidence. The next slice is the oldest card that has not started. Nothing is counted that is +// not a row, and a Project with no rows answers with no line at all. +export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now() }) { + const changed = row => since === null || (row?.updatedAt ?? 0) > since + const finished = rows.filter(row => row.column === 'done' && changed(row)) + const merged = finished.filter(row => row.source === 'workflow') + const closed = finished.filter(row => row.source !== 'workflow') + const blocked = rows.filter(row => row.column === 'in-progress' && text(row.assignee) !== '') + const next = rows + .filter(row => row.column === 'not-started') + .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id)))[0] + const line = (head, entries) => [head, ...entries.map(entry => `- ${entry}`)] + const sections = [ + ...(merged.length === 0 ? [] : line('Merged since the previous digest:', merged.map(row => `${row.id} (${row.source})`))), + ...(closed.length === 0 ? [] : line('Closed:', closed.map(row => `${row.id} (${row.source})`))), + ...(blocked.length === 0 ? [] : line('In progress, waiting on:', blocked.map(row => `${row.id} — ${text(row.assignee)}`))), + ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), + ] + const head = `${assistantName(project)} — ${stamp(at)}` + if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } + const stateLine = files.length === 0 + ? 'Project State: no Project State files read.' + : `Project State: ${files.map(file => file.path).join(', ')}` + return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } +} + +// A write is attributed by text, because one shared GitHub login makes it impossible to tell +// otherwise (#627). The login is the Project owner's own account, resolved at write time. +export const attribution = (project, login) => `Written-by: ${assistantName(project)}\nPosted-for: ${login}` + +export function install(rt) { + const projectOf = id => rt.readProject?.(id) ?? null + + const activeProject = (id) => { + const project = projectOf(text(id)) + if (project === null) throw refusal(`Unknown Project ${text(id) || '(none)'}`, 'bot/not-found') + if (project.status === 'archived') throw refusal(`Project ${project.name} is archived`, 'bot/refused') + return project + } + + const assistantOf = (project) => { + const botId = project.assistant?.botId + return typeof botId === 'string' && rt.botOf(botId) !== undefined ? project.assistant : null + } + + const memoryOf = (project) => { + const memory = rt.projectMemoryOf?.(project.id) + return { + decisions: Array.isArray(memory?.decisions) ? memory.decisions : [], + questions: Array.isArray(memory?.questions) ? memory.questions : [], + digest: memory?.digest ?? null, + } + } + + const read = (projectId) => ({ project: activeProject(projectId), memory: memoryOf(activeProject(projectId)) }) + + const write = (projectId, body = {}) => { + const project = activeProject(projectId) + const kind = text(body.kind) || 'decision' + if (!MEMORY_KINDS.includes(kind)) throw refusal(`Unknown Project memory kind ${kind}`) + const entry = text(body.text) + if (entry === '') throw refusal('A Project memory entry needs text') + if (entry.length > MAX_ENTRY) throw refusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) + rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) + return { project, memory: memoryOf(project) } + } + + // The one identity this slice accepts: the connector account, which on a single-person install + // is the owner's own. Two cases refuse and escalate instead of falling back to the app. + const identity = (projectId) => { + const project = activeProject(projectId) + const github = (rt.connectorsView?.() ?? []).find(row => row.id === 'github') + const login = text(github?.account) + if (login === '') { + return { ok: false, project, escalate: true, reason: 'no GitHub connector is signed in, so the assistant has no owner identity to post for' } + } + const members = Object.keys(rt.state?.members ?? {}) + // One member is the person signed in, so the Project owner must be that member. More than one + // member is exactly the case this slice has no answer for — there is no per-user GitHub linkage + // yet — so it refuses rather than letting one person's account post for another. + if (members.length > 1) { + return { ok: false, project, escalate: true, reason: `this host has more than one member and no per-user GitHub linkage, so Project ${project.name} cannot be posted for by any one owner` } + } + const own = members.length === 1 ? members[0] : '' + const ownerId = text(project.ownerUserId) || own + if (own !== '' && ownerId !== own) { + return { ok: false, project, escalate: true, reason: `Project ${project.name} is owned by ${ownerId}, not by this host's own user; there is no per-user GitHub linkage to post for them` } + } + return { ok: true, project, login } + } + + // One task on the assistant's Session, through the host's schedule path — never a cron of the + // plugin's own. An install with DSH's automation tasks off keeps the Project and records why the + // digest is not scheduled, because creating a Project must not depend on the schedule service. + const scheduleDigest = async (project, botId) => { + const sessionId = rt.chatOf?.(botId) ?? botId + if (typeof rt.scheduleEndpoint !== 'function') return { error: 'the schedule service is off in DSH' } + // The Bot exists but the Session index has not been rebuilt yet; a task on it must resolve as + // that Bot's own Session, or the schedule path refuses it as somebody else's. + rt.chatOwners?.set(sessionId, botId) + try { + const view = await rt.scheduleEndpoint('schedule-create', { + target: botId, + title: DIGEST_TITLE, + prompt: DIGEST_PROMPT, + when: { kind: 'daily', time: DIGEST_TIME, timeZone: DIGEST_ZONE }, + }) + const row = (view?.tasks ?? []).find(task => task.sessionId === sessionId && task.title === DIGEST_TITLE) + return { scheduleId: row?.id ?? null } + } catch (error) { + return { error: error?.message ?? String(error) } + } + } + + const create = async (projectId) => { + const project = activeProject(projectId) + const existing = assistantOf(project) + if (existing !== null) return { project, assistant: existing, created: false } + const bot = await rt.createBot({ + name: assistantName(project), + role: ASSISTANT_ROLE, + instructions: assistantInstructions(project), + hidden: true, + ownerId: text(project.ownerUserId) || rt.initialOwnerId?.(), + }) + // The assistant and its binding are plumbing, not a change of the Project: a Project + // created with one still answers `createdAt === updatedAt`. + const stamped = project.updatedAt + rt.bindProject?.({ kind: 'bot', id: bot.id, projectId: project.id }) + project.updatedAt = stamped + const schedule = await scheduleDigest(project, bot.id) + const assistant = { botId: bot.id, sessionId: rt.chatOf?.(bot.id) ?? bot.id, createdAt: Date.now(), ...schedule } + project.assistant = assistant + await rt.save?.() + return { project, assistant, created: true } + } + + const remove = async (projectId) => { + const project = projectOf(text(projectId)) + if (project === null) return null + const assistant = assistantOf(project) + if (assistant === null) return null + if (typeof rt.scheduleEndpoint === 'function' && typeof assistant.scheduleId === 'string') { + await rt.scheduleEndpoint('schedule-delete', { sessionId: assistant.sessionId, id: assistant.scheduleId }) + .catch(error => rt.warn?.('ds-bot: removing the Project Assistant schedule failed: %s', error?.message ?? error)) + } + await rt.deleteBot?.(assistant.botId) + .catch(error => rt.warn?.('ds-bot: removing the Project Assistant Bot failed: %s', error?.message ?? error)) + delete project.assistant + await rt.save?.() + return assistant + } + + // One stored Project note and one delivered message, at most once per calendar day. The second + // build in a day answers `skipped` and writes nothing; it still resolves identity first, because + // a refused identity must not leave a note behind. + const digest = async (projectId, { at = Date.now(), force = false } = {}) => { + const who = identity(projectId) + if (!who.ok) return { ok: false, escalated: who.escalate === true, reason: who.reason } + const { project, login } = who + const memory = memoryOf(project) + const day = dayIn(at) + if (!force && memory.digest?.day === day) { + return { ok: true, skipped: true, day, reason: 'today already has a digest', project } + } + const rows = rt.projectBoard?.(project.id) ?? [] + const listed = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) + const files = listed?.ok === true ? listed.files : [] + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at }) + const body = [attribution(project, login), built.text].join('\n\n') + const note = { day: built.day, at, text: built.text } + rt.setProjectDigest?.(project.id, note) + const toId = text(project.ownerUserId) === '' ? rt.state?.mainBotIds?.[0] : (rt.state?.members?.[project.ownerUserId]?.designatedChiefId ?? rt.state?.mainBotIds?.[0]) + if (typeof toId === 'string' && toId !== '') { + await rt.deliver?.({ + fromId: project.assistant?.botId ?? toId, + toId, + sessionId: rt.chatOf?.(toId) ?? toId, + text: body, + role: ASSISTANT_ROLE, + }) + } + return { ok: true, skipped: false, day: built.day, changed: built.changed, text: body, project } + } + + rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest } + + // The assistant's own Session calls this. The Project comes from the Session's bound Bot, never + // from the arguments, so one Project's assistant cannot reach another Project's memory. + rt.ctx.tools.register(defineTool({ + name: 'project_digest', + description: 'Build the Project digest for the Project your Session is bound to: what merged since the previous digest, what closed, which cards are still in progress and on whom, and the next slice. It stores one Project note and delivers one message to the owner, at most once per day; the second call in a day adds nothing and says so.', + parameters: {}, + output, + async execute(_args, exec) { + await rt.load?.() + const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const bot = botId === undefined ? undefined : rt.botOf(botId) + const projectId = text(bot?.projectId) + if (projectId === '') return 'This Session is not a Project Assistant, so it has no Project digest to build.' + const result = await digest(projectId) + if (result.ok !== true) return `The digest was not posted: ${result.reason}` + return result.skipped === true ? `Today already has a digest (${result.day}); this call added nothing.` : result.text + }, + })) +} diff --git a/core/src/host/store.js b/core/src/host/store.js index 11449408..923dbf65 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -31,7 +31,7 @@ export function install(rt) { /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record, tasks:Record, retiredSessions:Record, projects:Record, projectRuns:Record}} */ // `projects` is the durable Project record (#1691); `projectRuns` is the binding a workflow run // reports, because a run lives in the workflow plugin's own state and cannot carry the field. - const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, retiredSessions: {}, projects: {}, projectRuns: {}, revision: 0 } + const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, retiredSessions: {}, projects: {}, projectRuns: {}, projectMemory: {}, revision: 0 } let loaded = null let writing = Promise.resolve() // Every save this host has accepted since it started — a monotonic watermark, never decremented @@ -234,6 +234,12 @@ export function install(rt) { state.projectRuns = {} migrated = true } + // A Project Assistant's memory (#1712) is keyed by Project id and read by that id alone, so one + // Project's entries never appear in another's answer. + if (!isRecord(state.projectMemory)) { + state.projectMemory = {} + migrated = true + } if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true @@ -414,6 +420,59 @@ export function install(rt) { } const readProject = id => projectOfId(id) const listProjects = () => Object.values(state.projects).filter(isRecord) + // A Project Assistant's memory (#1712): decisions, open questions and the last digest, stored + // per Project id. A Project with no entries answers empty lists, never another Project's. + const projectMemoryOf = (id) => { + const kept = ownEntry(state.projectMemory, String(id ?? '')) + return isRecord(kept) ? kept : null + } + const projectMemory = (id) => { + const project = projectOfId(id) + if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') + const kept = projectMemoryOf(project.id) + return { + decisions: Array.isArray(kept?.decisions) ? kept.decisions : [], + questions: Array.isArray(kept?.questions) ? kept.questions : [], + digest: isRecord(kept?.digest) ? kept.digest : null, + } + } + const memoryTable = (project) => { + const kept = projectMemoryOf(project.id) + if (kept !== null) return kept + const fresh = { decisions: [], questions: [], digest: null } + setEntry(state.projectMemory, project.id, fresh) + return fresh + } + const appendProjectMemory = (id, kind, entry = {}) => { + const project = requireProject(id) + if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) + const table = memoryTable(project) + const list = kind === 'decision' ? table.decisions : table.questions + list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) + project.updatedAt = Math.max(project.updatedAt, Date.now()) + return projectMemory(project.id) + } + // The last digest is one note per Project, replaced only by a later day's build; the assistant + // refuses a second build in the same day before it gets here. + const setProjectDigest = (id, note = {}) => { + const project = requireProject(id) + memoryTable(project).digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000) } + project.updatedAt = Math.max(project.updatedAt, Date.now()) + return projectMemory(project.id) + } + // Deleting a Project takes its bindings and its memory with it (#1712): an unbound member + // resolves `null` again, and no orphan memory keyed by a dead id can be read. + const deleteProject = async (id) => { + const project = requireProject(id) + await rt.projectAssistant?.remove?.(project.id) + delete state.projects[project.id] + delete state.projectMemory[project.id] + for (const member of [...Object.values(state.bots), ...Object.values(state.rooms)]) { + if (member.projectId === project.id) delete member.projectId + } + for (const [runId, bound] of Object.entries(state.projectRuns)) if (bound === project.id) delete state.projectRuns[runId] + return project + } // One Project to many members, a member to at most one Project; `projectId: null` unbinds. const bindProject = (body = {}) => { const kind = projectText(body.kind, 20) @@ -456,9 +515,31 @@ export function install(rt) { // The caller is stamped as the owner; a member id from the body cannot mint a // Project someone else manages. const project = createProject({ ...body, ownerUserId: projectActorId(actor) ?? undefined }) + // One assistant per Project (#1712). A Project must not fail because automation is off, so + // a refusal here is recorded on the record and the Project is still created. + if (rt.projectAssistant !== undefined) { + try { + await rt.projectAssistant.create(project.id) + } catch (error) { + project.assistant = { error: error?.message ?? String(error) } + } + } await save() return { project } } + case 'project-delete': { + const project = await deleteProject(projectText(body.id, 200)) + await save() + return { project } + } + // The assistant's private memory (#1712): read and write are per Project id, so a caller + // holding one id can never reach another Project's entries. + case 'project-memory': return { project: readProject(projectText(body.id, 200)), memory: projectMemory(projectText(body.id, 200)) } + case 'project-memory-write': { + const memory = appendProjectMemory(projectText(body.id, 200), projectText(body.kind, 20), body) + await save() + return { memory } + } case 'project-read': { const project = readProject(projectText(body.id, 200)) return { project, canManage: projectCanManage(project, actor) } @@ -771,5 +852,6 @@ export function install(rt) { groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, initialOwnerId, syncDesignatedChief, projectEndpoint, createProject, readProject, listProjects, bindProject, projectOfMember, + projectMemory, projectMemoryOf, appendProjectMemory, setProjectDigest, deleteProject, }) } diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 1214ac4d..3d5a21b5 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -28,7 +28,7 @@ export const EPHEMERAL_DENIED_TOOLS = Object.freeze([ 'send_message', 'schedule_create', 'schedule_update', 'schedule_delete', 'message_friend_bot', 'get_friend_file', 'list_friend_bots', 'define_workflow', 'run_workflow', - 'read_browser', 'recall', 'create_child_bot', + 'read_browser', 'recall', 'create_child_bot', 'project_digest', ]) // The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself // may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run diff --git a/core/test/install.test.js b/core/test/install.test.js index 4f5eb61f..077ee11f 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -109,6 +109,7 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'effect ds-bot: capabilities', 'effect ds-bot: missed scheduled runs', 'tool bot_schedules', + 'tool project_digest', 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js new file mode 100644 index 00000000..44ec1b7e --- /dev/null +++ b/core/test/project-assistant.test.js @@ -0,0 +1,234 @@ +// The Project Assistant (#1712): one hidden assistant per Project, memory that never crosses a +// Project id, a digest built only from the Project State reader and the board rows, and exactly one +// 09:00 Asia/Shanghai task on the assistant's own Session that goes away with the Project. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' +import { buildDigest, dayIn } from '../src/host/project-assistant.js' + +const seed = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' } }, + rooms: {}, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, + grants: {}, +}) + +// DSH's schedule service, in memory: enough for the host's own create/delete path and its view. +function scheduleService() { + const tasks = new Map() + const created = [] + let next = 1 + return { + tasks, + created, + async create(sessionId, request) { + created.push({ sessionId, request }) + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], kind, scheduledAt: '2030-01-01T01:00:00.000Z' } + tasks.set(id, { sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async delete({ sessionId, id }) { + const task = tasks.get(id) + if (task === undefined || task.sessionId !== sessionId) return { deleted: false } + tasks.delete(id) + return { deleted: true } + }, + } +} + +async function start({ github = 'Birfy' } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-assistant-')) + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const service = scheduleService() + const ctx = fakeContext({ services: { schedule: service } }) + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) + return agents.get(sessionId) + } + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + rt.connectorsView = () => (github === null ? [] : [{ id: 'github', name: 'GitHub', state: 'ready', account: github }]) + const delivered = [] + rt.deliver = async message => { delivered.push(message) } + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { rt, service, delivered, stop } +} + +const created = async (rt, body) => (await rt.projectEndpoint('project-create', body)).project + +test('memory is private to one Project and an unknown id is refused', async () => { + const { rt, stop } = await start() + try { + const one = await created(rt, { name: 'One', ownerUserId: 'owner-1' }) + const two = await created(rt, { name: 'Two', ownerUserId: 'owner-1' }) + await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'decision', text: 'Ship the memory store' }) + await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'question', text: 'Who reviews it?' }) + const mine = (await rt.projectEndpoint('project-memory', { id: one.id })).memory + assert.deepEqual(mine.decisions.map(entry => entry.text), ['Ship the memory store']) + assert.deepEqual(mine.questions.map(entry => entry.text), ['Who reviews it?']) + const theirs = (await rt.projectEndpoint('project-memory', { id: two.id })).memory + assert.deepEqual(theirs.decisions, []) + assert.deepEqual(theirs.questions, []) + await assert.rejects(() => rt.projectEndpoint('project-memory', { id: 'project-missing' }), /Unknown Project/) + assert.throws(() => rt.projectAssistant.read('project-missing'), error => error.code === 'bot/not-found') + } finally { + await stop() + } +}) + +test('the assistant refuses an unknown Project and an archived one', async () => { + const { rt, stop } = await start() + try { + await assert.rejects(() => rt.projectAssistant.create('project-missing'), error => error.code === 'bot/not-found') + const project = await created(rt, { name: 'Archived', ownerUserId: 'owner-1' }) + rt.readProject(project.id).status = 'archived' + await assert.rejects(() => rt.projectAssistant.create(project.id), /is archived/) + } finally { + await stop() + } +}) + +test('the digest names only the facts the rows carry, in the four-section order', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Digest', ownerUserId: 'owner-1' }) + const at = Date.parse('2026-10-29T01:00:00Z') + rt.projectBoard = () => [ + { id: 't-merged', source: 'workflow', column: 'done', assignee: null, updatedAt: at - 60_000 }, + { id: 't-closed', source: 'bot', column: 'done', assignee: null, updatedAt: at - 30_000 }, + { id: 't-waiting', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: at - 10_000 }, + { id: 't-next', source: 'group', column: 'not-started', assignee: null, updatedAt: at - 5_000 }, + ] + const result = await rt.projectAssistant.digest(project.id, { at }) + assert.equal(result.ok, true) + assert.equal(result.skipped, false) + const text = result.text + assert.match(text, /^Written-by: Project Assistant \(Digest\)\nPosted-for: Birfy\n/) + for (const id of ['t-merged', 't-closed', 't-waiting', 't-next']) assert.match(text, new RegExp(id)) + assert.match(text, /waiting on:\n- t-waiting — Writer|In progress, waiting on:\n- t-waiting — Writer/) + assert.doesNotMatch(text, /t-absent/, 'a row that does not exist is never named') + const merged = text.indexOf('Merged since the previous digest:') + const closed = text.indexOf('Closed:') + const blocked = text.indexOf('In progress, waiting on:') + const next = text.indexOf('Next slice:') + assert.ok(merged > 0 && merged < closed && closed < blocked && blocked < next, text) + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test('a day with no change is one line and the second build adds nothing', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Quiet', ownerUserId: 'owner-1' }) + const at = Date.parse('2026-10-29T01:00:00Z') + rt.projectBoard = () => [] + const first = await rt.projectAssistant.digest(project.id, { at }) + assert.equal(first.changed, false) + assert.match(first.text, /no change since the previous digest\.$/) + const second = await rt.projectAssistant.digest(project.id, { at: at + 60_000 }) + assert.equal(second.skipped, true) + assert.equal(second.reason, 'today already has a digest') + assert.equal(delivered.length, 1, 'one delivery for one day') + assert.equal(dayIn(at), dayIn(at + 60_000)) + const note = rt.projectMemoryOf(project.id).digest + assert.equal(note.day, dayIn(at)) + assert.match(note.text, /no change/) + } finally { + await stop() + } +}) + +test('identity refuses with no connector and on a multi-member host that is not the owner', async () => { + const { rt, delivered, stop } = await start({ github: null }) + try { + const project = await created(rt, { name: 'No Identity', ownerUserId: 'owner-1' }) + const refused = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(refused.ok, false) + assert.equal(refused.escalated, true) + assert.match(refused.reason, /no GitHub connector is signed in/) + assert.equal(delivered.length, 0, 'a refused identity posts nothing') + assert.equal(rt.projectMemoryOf(project.id)?.digest ?? null, null, 'and leaves no note behind') + + rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } + const other = await created(rt, { name: 'Somebody Else', ownerUserId: 'owner-2' }) + const refusedToo = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(refusedToo.ok, false) + assert.equal(refusedToo.escalated, true) + assert.match(refusedToo.reason, /no per-user GitHub linkage/) + assert.equal(delivered.length, 0) + } finally { + await stop() + } +}) + +test('project-create makes exactly one 09:00 Asia/Shanghai task and project-delete takes it away', async () => { + const { rt, service, stop } = await start() + try { + const project = await created(rt, { name: 'Scheduled', ownerUserId: 'owner-1' }) + assert.equal(service.created.length, 1) + const request = service.created[0].request + assert.equal(request.title, '09:00 Project digest') + assert.deepEqual(request.daily, { time: '09:00:00', time_zone: 'Asia/Shanghai' }) + assert.equal(project.assistant.scheduleId, 'schedule-1') + const assistant = rt.botOf(project.assistant.botId) + assert.equal(assistant.hidden, true) + assert.equal(assistant.projectId, project.id) + assert.equal(service.tasks.size, 1) + + await rt.projectEndpoint('project-delete', { id: project.id }) + assert.equal(rt.readProject(project.id), null) + assert.equal(service.tasks.size, 0) + assert.equal(rt.projectMemoryOf(project.id), null) + } finally { + await stop() + } +}) + +test('buildDigest is pure: the same rows answer the same four sections', () => { + const project = { id: 'project-1', name: 'Pure' } + const rows = [ + { id: 'b', source: 'workflow', column: 'done', assignee: null, updatedAt: 20 }, + { id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }, + ] + const one = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) + const two = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(one.text, two.text) + assert.match(one.text, /Project Assistant \(Pure\) — 2026-10-29/) + const after = buildDigest({ project, rows, since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.doesNotMatch(after.text, /b \\(workflow\\)/, 'a finished card older than the previous digest is not reported again') + assert.match(after.text, /waiting on/) + const quiet = buildDigest({ project, rows: [], since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(quiet.changed, false) + assert.match(quiet.text, /no change since the previous digest\.$/) +}) diff --git a/core/test/run-bot-tools.test.js b/core/test/run-bot-tools.test.js index cfb68658..ae7ce56e 100644 --- a/core/test/run-bot-tools.test.js +++ b/core/test/run-bot-tools.test.js @@ -111,8 +111,8 @@ test('the constant refuses every name at the guard and the approval seam, with n const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() t.after(stop) const id = await runBot(fb, []) - assert.equal(EPHEMERAL_DENIED_TOOLS.length, 30) - assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 30, 'no duplicate name') + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 31) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 31, 'no duplicate name') assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only') diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 979194ac..28df2f1a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -13,6 +13,8 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). The sidebar **+** new-chat picker (`core/src/client/new-chat.js`) takes extra rows from slot `flybot.new-chat.action` (after New group chat; `label` / `hint` go through the same `t()` map) and lists only `listedChatBots` (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by `core/test/new-chat.test.js` in patch `0003-flybot-api-client`. +- Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. + ## Does not own - The server side, `core/index.js`, `core/src/host/`, `core/src/shared/` → [core-host](core-host.md). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 187016eb..b21e60d6 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,6 +50,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time: no prompt, system message or Session context is written by a call. Tests: `core/test/project-state.test.js`. | +| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Tests: `core/test/project-assistant.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | | `core/src/shared/preview.js` | What a stored line may show a person (#130, #1347): `visibleText(text)` unwraps a complete `` wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. `stripFence` is the marker half alone. The sidebar rows read it (`previewOf` in `src/client/text.js`), and so does the `preview` this channel puts on a room in the `state` payload, which used to be a plain `unwrapUntrusted` and so leaked `from="User"> … >` on a line whose opener had already been cut. Tests: `core/test/ui-clickthrough.test.js`. | | `core/src/shared/failure-copy.js` | A failed turn's sentence a person can act on (#1347): `failureSentence(raw, translate)` keys on the provider's own `code` field in the stored text — not on the message, which is not stable — and returns the mapped sentence (naming the feature in an `UnsupportedModel` refusal, and where to change the model), or the text unchanged when the code is not mapped, because a failure nobody has mapped reads better as it came than as a guess. `src/shared/turn-failures.js` and `src/client/inbox.js` both read it, so the chat's dead-turn lines and the Inbox card say the same thing. Tests: `core/test/ui-clickthrough.test.js`. | @@ -207,7 +208,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Tests: `core/test/project-board.test.js`. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. - One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad4d5a15..57f6756d 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9258,7 +9258,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'on agent/status', 'on llm/stream', 'on llm/stream', -@@ -91,12 +91,23 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -91,12 +91,24 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -9269,6 +9269,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: capabilities', + 'effect ds-bot: missed scheduled runs', + 'tool bot_schedules', ++ 'tool project_digest', + 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 33abfdc8..e472f271 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -32,13 +32,14 @@ diff --git a/index.js b/index.js import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' -@@ -24,11 +24,16 @@ import * as images from './src/host/images.js' +@@ -24,11 +24,17 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' import * as connectors from './src/host/connectors.js' +import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' ++import * as projectAssistant from './src/host/project-assistant.js' +import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' @@ -54,7 +55,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -92,7 +93,7 @@ diff --git a/src/host/constants.js b/src/host/constants.js - 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', - 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', + 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'archive_bot', 'list_models', -+ 'bot_schedules', 'project_state', ++ 'bot_schedules', 'project_state', 'project_digest', + 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', @@ -666,7 +667,7 @@ diff --git a/src/host/store.js b/src/host/store.js + /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record, tasks:Record, retiredSessions:Record, projects:Record, projectRuns:Record}} */ + // `projects` is the durable Project record (#1691); `projectRuns` is the binding a workflow run + // reports, because a run lives in the workflow plugin's own state and cannot carry the field. -+ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, retiredSessions: {}, projects: {}, projectRuns: {}, revision: 0 } ++ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, retiredSessions: {}, projects: {}, projectRuns: {}, projectMemory: {}, revision: 0 } let loaded = null let writing = Promise.resolve() + // Every save this host has accepted since it started — a monotonic watermark, never decremented @@ -802,7 +803,7 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,14 @@ export function install(rt) { +@@ -104,6 +104,20 @@ export function install(rt) { // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -813,6 +814,12 @@ diff --git a/src/host/store.js b/src/host/store.js + if (!isRecord(state.projectRuns)) { + state.projectRuns = {} + migrated = true ++ } ++ // A Project Assistant's memory (#1712) is keyed by Project id and read by that id alone, so one ++ // Project's entries never appear in another's answer. ++ if (!isRecord(state.projectMemory)) { ++ state.projectMemory = {} ++ migrated = true + } if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } @@ -841,7 +848,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,215 @@ export function install(rt) { +@@ -161,12 +161,290 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -959,6 +966,59 @@ diff --git a/src/host/store.js b/src/host/store.js + } + const readProject = id => projectOfId(id) + const listProjects = () => Object.values(state.projects).filter(isRecord) ++ // A Project Assistant's memory (#1712): decisions, open questions and the last digest, stored ++ // per Project id. A Project with no entries answers empty lists, never another Project's. ++ const projectMemoryOf = (id) => { ++ const kept = ownEntry(state.projectMemory, String(id ?? '')) ++ return isRecord(kept) ? kept : null ++ } ++ const projectMemory = (id) => { ++ const project = projectOfId(id) ++ if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') ++ const kept = projectMemoryOf(project.id) ++ return { ++ decisions: Array.isArray(kept?.decisions) ? kept.decisions : [], ++ questions: Array.isArray(kept?.questions) ? kept.questions : [], ++ digest: isRecord(kept?.digest) ? kept.digest : null, ++ } ++ } ++ const memoryTable = (project) => { ++ const kept = projectMemoryOf(project.id) ++ if (kept !== null) return kept ++ const fresh = { decisions: [], questions: [], digest: null } ++ setEntry(state.projectMemory, project.id, fresh) ++ return fresh ++ } ++ const appendProjectMemory = (id, kind, entry = {}) => { ++ const project = requireProject(id) ++ if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) ++ const table = memoryTable(project) ++ const list = kind === 'decision' ? table.decisions : table.questions ++ list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) ++ project.updatedAt = Math.max(project.updatedAt, Date.now()) ++ return projectMemory(project.id) ++ } ++ // The last digest is one note per Project, replaced only by a later day's build; the assistant ++ // refuses a second build in the same day before it gets here. ++ const setProjectDigest = (id, note = {}) => { ++ const project = requireProject(id) ++ memoryTable(project).digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000) } ++ project.updatedAt = Math.max(project.updatedAt, Date.now()) ++ return projectMemory(project.id) ++ } ++ // Deleting a Project takes its bindings and its memory with it (#1712): an unbound member ++ // resolves `null` again, and no orphan memory keyed by a dead id can be read. ++ const deleteProject = async (id) => { ++ const project = requireProject(id) ++ await rt.projectAssistant?.remove?.(project.id) ++ delete state.projects[project.id] ++ delete state.projectMemory[project.id] ++ for (const member of [...Object.values(state.bots), ...Object.values(state.rooms)]) { ++ if (member.projectId === project.id) delete member.projectId ++ } ++ for (const [runId, bound] of Object.entries(state.projectRuns)) if (bound === project.id) delete state.projectRuns[runId] ++ return project ++ } + // One Project to many members, a member to at most one Project; `projectId: null` unbinds. + const bindProject = (body = {}) => { + const kind = projectText(body.kind, 20) @@ -1001,9 +1061,31 @@ diff --git a/src/host/store.js b/src/host/store.js + // The caller is stamped as the owner; a member id from the body cannot mint a + // Project someone else manages. + const project = createProject({ ...body, ownerUserId: projectActorId(actor) ?? undefined }) ++ // One assistant per Project (#1712). A Project must not fail because automation is off, so ++ // a refusal here is recorded on the record and the Project is still created. ++ if (rt.projectAssistant !== undefined) { ++ try { ++ await rt.projectAssistant.create(project.id) ++ } catch (error) { ++ project.assistant = { error: error?.message ?? String(error) } ++ } ++ } + await save() + return { project } + } ++ case 'project-delete': { ++ const project = await deleteProject(projectText(body.id, 200)) ++ await save() ++ return { project } ++ } ++ // The assistant's private memory (#1712): read and write are per Project id, so a caller ++ // holding one id can never reach another Project's entries. ++ case 'project-memory': return { project: readProject(projectText(body.id, 200)), memory: projectMemory(projectText(body.id, 200)) } ++ case 'project-memory-write': { ++ const memory = appendProjectMemory(projectText(body.id, 200), projectText(body.kind, 20), body) ++ await save() ++ return { memory } ++ } + case 'project-read': { + const project = readProject(projectText(body.id, 200)) + return { project, canManage: projectCanManage(project, actor) } @@ -1197,7 +1279,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,28 @@ export function install(rt) { +@@ -335,23 +335,29 @@ export function install(rt) { } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1228,6 +1310,7 @@ diff --git a/src/host/store.js b/src/host/store.js groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, + initialOwnerId, syncDesignatedChief, + projectEndpoint, createProject, readProject, listProjects, bindProject, projectOfMember, ++ projectMemory, projectMemoryOf, appendProjectMemory, setProjectDigest, deleteProject, }) } diff --git a/src/host/tools.js b/src/host/tools.js @@ -1266,7 +1349,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + 'send_message', 'schedule_create', 'schedule_update', 'schedule_delete', + 'message_friend_bot', 'get_friend_file', 'list_friend_bots', + 'define_workflow', 'run_workflow', -+ 'read_browser', 'recall', 'create_child_bot', ++ 'read_browser', 'recall', 'create_child_bot', 'project_digest', +]) +// The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself +// may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run @@ -1549,11 +1632,10 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (bot === undefined) return `No Bot named ${args.bot}.` if (bot.id !== caller.id && !rt.isMain(self)) return 'Only a Main Bot can change another Bot. Leave out bot to change yourself, or ask a Main Bot with message_bot.' try { -@@ -230,6 +230,30 @@ export function install(rt) { - }, +@@ -231,6 +231,30 @@ export function install(rt) { })) -+ ctx.tools.register(defineTool({ + ctx.tools.register(defineTool({ + name: 'archive_bot', + description: 'Main Bot only. Retire another Bot: it leaves the tree (freeing a maxChildren slot) and the default Bot list, while its chats, history and memory stay readable. It is never deleted and no parent moves; deleting a Bot stays the owner\'s own click in the UI.', + parameters: { @@ -1577,9 +1659,10 @@ diff --git a/src/host/tools.js b/src/host/tools.js + }, + })) + - ctx.tools.register(defineTool({ ++ ctx.tools.register(defineTool({ name: 'list_models', description: 'List the models a Bot can run on, as provider/model refs for create_bot and update_bot.', + parameters: {}, @@ -251,13 +251,13 @@ export function install(rt) { const bots = [] const unknown = [] diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 53904aa1..9b6398fb 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -904,7 +904,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', + 'list_capabilities', 'save_capability', 'delete_capability', + 'attach_capability', 'detach_capability', -+ 'track_links', 'project_state', ++ 'track_links', 'project_state', 'project_digest', +]) +const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) +const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 0402def5..aab3e019 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -288,7 +288,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) // Programming and file system errors are DS Bot's own faults, logged with their stack; -@@ -21,32 +21,151 @@ +@@ -21,32 +21,151 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale @@ -449,7 +449,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // The team's DSH Agent (at most one): plain dsh Sessions, not Bots. agents: Object.values(state.agents), // Key names and who may use them; values never leave the host. -@@ -54,7 +54,8 @@ +@@ -54,7 +54,8 @@ export function install(rt) { capabilities: rt.capabilityLibraryView(), secretRequests: state.secretRequests ?? {}, colors: [...COLORS, ...EXTRA_COLORS], @@ -459,7 +459,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,50 @@ +@@ -65,22 +65,50 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -521,7 +521,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,64 @@ +@@ -97,28 +97,64 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -590,7 +590,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'duplicate-bot': await rt.load() return ok(await rt.duplicateBot(body.id)) -@@ -141,7 +141,7 @@ +@@ -141,7 +141,7 @@ export function install(rt) { return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) case 'create-bot': { await rt.bootstrap() @@ -599,7 +599,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(bot) } case 'update-bot': -@@ -150,18 +150,23 @@ +@@ -150,18 +150,23 @@ export function install(rt) { case 'delete-bot': await rt.load() await rt.deleteBot(body.id) @@ -627,7 +627,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -171,12 +171,70 @@ +@@ -171,12 +171,70 @@ export function install(rt) { notice: body.notice, mode: body.mode, admin: body.admin, @@ -700,7 +700,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js } case 'folders': case 'folder-pick': -@@ -203,17 +203,19 @@ +@@ -203,17 +203,19 @@ export function install(rt) { case 'remove-agent': await rt.load() await rt.removeAgent(body.agentId) @@ -723,7 +723,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'stop-member-work': return ok(await rt.stopMemberWork(String(body.roomId ?? ''), String(body.botId ?? ''))) case 'dismiss-question': -@@ -231,8 +231,14 @@ +@@ -231,8 +231,14 @@ export function install(rt) { await rt.load() return ok(await rt.secretEndpoint(endpoint, body)) case 'connector-connect': @@ -738,7 +738,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.load() return ok(await rt.connectorEndpoint(endpoint, body)) case 'capability-save': -@@ -241,11 +241,24 @@ +@@ -241,11 +241,24 @@ export function install(rt) { case 'bot-capability': await rt.load() return ok(await rt.capabilityEndpoint(endpoint, body)) @@ -764,7 +764,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'memory-view': case 'memory-ask': case 'memory-forget': -@@ -258,8 +258,17 @@ +@@ -258,8 +258,17 @@ export function install(rt) { const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId const question = state.questions[sessionId] if (question === undefined || question.id !== body.questionId) return fail('This question is no longer open', 'bot/stale') @@ -782,7 +782,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // Claim the question before queueing so a second answer is refused. delete state.questions[sessionId] try { -@@ -278,9 +278,49 @@ +@@ -278,9 +278,49 @@ export function install(rt) { const text = String(body.text ?? '').trim() if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') await rt.load() @@ -832,7 +832,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,32 +319,129 @@ +@@ -319,32 +319,132 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -862,6 +862,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'project-bind': + case 'project-of': + case 'project-board': ++ case 'project-delete': ++ case 'project-memory': ++ case 'project-memory-write': + await rt.load() + return ok(await rt.projectEndpoint(endpoint, body, actor)) + case 'share-brief': diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 85583211..35f003f1 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -42,6 +42,56 @@ +@@ -42,6 +42,56 @@ module.exports = __toCommonJS(index_exports); // src/client/i18n.js var NAMESPACE = "ds-bot"; var ZH = { @@ -60,7 +60,7 @@ diff --git a/client.js b/client.js // Shared "Main Bot": "主 Bot", Working: "工作中", -@@ -49,6 +49,67 @@ +@@ -49,6 +49,67 @@ var ZH = { "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -128,7 +128,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +63,21 @@ +@@ -63,6 +63,21 @@ var ZH = { Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -150,7 +150,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,72 @@ +@@ -78,21 +78,72 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -226,7 +226,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,59 @@ +@@ -129,6 +129,59 @@ var ZH = { "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -286,7 +286,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,64 @@ +@@ -141,17 +141,64 @@ var ZH = { "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -355,7 +355,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +162,6 @@ +@@ -162,8 +162,6 @@ var ZH = { "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -364,7 +364,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +187,24 @@ +@@ -187,9 +187,24 @@ var ZH = { Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -389,7 +389,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -205,6 +205,16 @@ +@@ -205,6 +205,16 @@ var ZH = { "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", @@ -406,7 +406,7 @@ diff --git a/client.js b/client.js "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", -@@ -247,6 +247,25 @@ +@@ -247,6 +247,25 @@ var ZH = { "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -432,7 +432,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +270,7 @@ +@@ -270,6 +270,7 @@ var ZH = { "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -440,7 +440,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +303,8 @@ +@@ -303,6 +303,8 @@ var ZH = { "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -449,7 +449,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +374,10 @@ +@@ -374,9 +374,10 @@ var ZH = { "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -462,7 +462,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +385,6 @@ +@@ -385,7 +385,6 @@ var ZH = { Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -470,7 +470,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +394,8 @@ +@@ -394,6 +394,8 @@ var ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -479,7 +479,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +404,13 @@ +@@ -404,13 +404,13 @@ var ZH = { "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -499,7 +499,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,74 @@ +@@ -420,8 +420,74 @@ var ZH = { "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -575,7 +575,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,13 @@ +@@ -446,10 +446,13 @@ var ZH = { Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -589,7 +589,7 @@ diff --git a/client.js b/client.js "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", -@@ -459,6 +459,9 @@ +@@ -459,6 +459,9 @@ var ZH = { "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -599,7 +599,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +468,8 @@ +@@ -468,6 +468,8 @@ var ZH = { "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -608,7 +608,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +495,6 @@ +@@ -495,7 +495,6 @@ var ZH = { "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -616,7 +616,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,31 @@ +@@ -506,12 +506,31 @@ var ZH = { "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -649,7 +649,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -533,13 +533,14 @@ +@@ -533,13 +533,14 @@ var ZH = { Selected: "已选", Skip: "跳过", Submit: "提交", @@ -665,7 +665,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +555,6 @@ +@@ -555,7 +555,6 @@ var ZH = { "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -673,7 +673,7 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -582,7 +582,7 @@ +@@ -582,7 +582,7 @@ var ZH = { "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -682,7 +682,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -621,7 +621,6 @@ +@@ -621,7 +621,6 @@ var ZH = { Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", @@ -690,7 +690,7 @@ diff --git a/client.js b/client.js "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", -@@ -747,11 +747,11 @@ +@@ -747,11 +747,11 @@ var ZH = { "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -705,7 +705,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ +@@ -761,6 +761,13 @@ var ZH = { "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -719,7 +719,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +776,7 @@ +@@ -776,7 +776,7 @@ var ZH = { Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -728,7 +728,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +788,7 @@ +@@ -788,7 +788,7 @@ var ZH = { "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -737,7 +737,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,12 +821,21 @@ +@@ -821,12 +821,21 @@ var ZH = { "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -759,7 +759,7 @@ diff --git a/client.js b/client.js "Once, {time}": "一次,{time}", "Every day at {time}": "每天 {time}", "Every {days} at {time}": "每{days} {time}", -@@ -834,7 +834,21 @@ +@@ -834,7 +834,21 @@ var ZH = { "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -781,7 +781,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -844,6 +844,7 @@ +@@ -844,6 +844,7 @@ var ZH = { minutes: "分钟", hours: "小时", days: "天", @@ -789,7 +789,7 @@ diff --git a/client.js b/client.js "Morning briefing": "早间简报", "What to do": "要做什么", "Sum up what changed since yesterday and what needs me today.": "总结一下昨天以来的变化,以及今天需要我处理的事。", -@@ -856,9 +856,20 @@ +@@ -856,9 +856,20 @@ var ZH = { Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -812,7 +812,7 @@ diff --git a/client.js b/client.js "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", "Automation tasks are off in DSH": "DSH 的「自动化任务」没有打开", -@@ -869,10 +869,19 @@ +@@ -869,10 +869,19 @@ var ZH = { "Pick a date and time": "选一个日期和时间", "Pick a time in the future": "选一个将来的时间", "Pick a time of day": "选一个时间", @@ -832,7 +832,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +887,14 @@ +@@ -887,7 +887,14 @@ var ZH = { "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -848,7 +848,7 @@ diff --git a/client.js b/client.js }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], -@@ -900,8 +900,11 @@ +@@ -900,8 +900,11 @@ var HOST_ERRORS = [ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, "只有你、主 Bot 或「$1」的管理员可以修改"], [/^A key must have at least (\d+) characters$/, "密钥至少 $1 个字符"], [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], @@ -860,7 +860,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -930,6 +930,20 @@ +@@ -930,6 +930,20 @@ var HOST_ZH = { "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -881,7 +881,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,10 +951,12 @@ +@@ -951,10 +951,12 @@ var HOST_ZH = { "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -896,7 +896,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1005,15 +1005,23 @@ +@@ -1005,15 +1005,23 @@ function noteClientError(where, error) { } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -923,7 +923,7 @@ diff --git a/client.js b/client.js if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); -@@ -1042,16 +1042,21 @@ +@@ -1042,16 +1042,21 @@ var FAILURES = { }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -951,7 +951,7 @@ diff --git a/client.js b/client.js const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} -@@ -1066,10 +1066,16 @@ +@@ -1066,10 +1066,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- var call = async (endpoint, payload, signal) => { let response; try { @@ -969,7 +969,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1087,7 @@ +@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -978,7 +978,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1097,388 @@ +@@ -1097,6 +1097,388 @@ var call = async (endpoint, payload, signal) => { return result.value; }; @@ -1367,7 +1367,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1160,9 +1160,9 @@ +@@ -1160,9 +1160,9 @@ var MAIN_LOOK = Object.freeze({ shape: "whale", color: "deepseek" }); var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -1379,7 +1379,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +1177,96 @@ +@@ -1177,19 +1177,96 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1479,7 +1479,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +1206,8 @@ +@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } @@ -1490,7 +1490,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,192 +1242,46 @@ +@@ -1242,192 +1242,46 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1544,17 +1544,8 @@ diff --git a/client.js b/client.js - else delete own[callId]; - } - return { ...state, [sessionId]: own }; -+// src/client/cluster-order.js -+function clusterOrder(people2) { -+ const list = Array.isArray(people2) ? people2 : []; -+ const remote = (bot) => Boolean(bot?.remote); -+ if (!list.some(remote)) return list; -+ const locals = list.filter((bot) => !remote(bot)); -+ const remotes = list.filter(remote); -+ if (list.length <= 4) return [...remotes, ...locals]; -+ return [...locals, ...remotes]; - } - +-} +- -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; -var THEMES = { @@ -1643,7 +1634,16 @@ diff --git a/client.js b/client.js - else delete SHAPES[id]; - refresh(); - }; --} ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } -function registerTheme(id, theme) { - if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); - for (const mode of ["light", "dark"]) { @@ -1659,7 +1659,7 @@ diff --git a/client.js b/client.js - }; -} -var allThemes = () => ({ ...THEMES, ...customThemes }); -- + // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; @@ -1702,7 +1702,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -1445,24 +1445,86 @@ +@@ -1445,24 +1445,86 @@ function mentionSpans(text, bots, { references = [] } = {}) { } return spans; } @@ -1794,7 +1794,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,9 +1483,18 @@ +@@ -1483,9 +1483,18 @@ function relativeShort(ms) { var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1816,7 +1816,7 @@ diff --git a/client.js b/client.js try { const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); -@@ -1493,38 +1493,121 @@ +@@ -1493,38 +1493,121 @@ function reminderPrompts(text) { return []; } } @@ -1945,7 +1945,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,17 +1597,46 @@ +@@ -1597,17 +1597,46 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { } return { count, peerIds }; } @@ -1995,7 +1995,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,9 +1624,982 @@ +@@ -1624,9 +1624,982 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2979,7 +2979,7 @@ diff --git a/client.js b/client.js var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +1659,12 @@ +@@ -1659,12 +1659,12 @@ function liveSprings(apply2) { const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -2995,7 +2995,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +1699,10 @@ +@@ -1699,10 +1699,10 @@ function liveSprings(apply2) { } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -3009,7 +3009,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +1736,7 @@ +@@ -1736,7 +1736,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -3018,7 +3018,7 @@ diff --git a/client.js b/client.js const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +1750,7 @@ +@@ -1750,7 +1750,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } place(fresh); }); @@ -3027,7 +3027,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +1758,16 @@ +@@ -1758,16 +1758,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -3048,7 +3048,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +1788,8 @@ +@@ -1788,8 +1788,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { }, [key]); } function useMorph(box, key) { @@ -3059,7 +3059,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +1817,8 @@ +@@ -1817,8 +1817,8 @@ function useMorph(box, key) { }); } function useLinger(value, ms) { @@ -3070,7 +3070,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +1838,7 @@ +@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -3078,7 +3078,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +1856,14 @@ +@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -3093,7 +3093,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +1933,23 @@ +@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -3119,7 +3119,7 @@ diff --git a/client.js b/client.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -1950,27 +1950,32 @@ +@@ -1950,27 +1950,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3156,7 +3156,7 @@ diff --git a/client.js b/client.js .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} -@@ -1992,6 +1992,7 @@ +@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -3164,7 +3164,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +2085,7 @@ +@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -3172,7 +3172,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +2098,9 @@ +@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3183,7 +3183,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +2127,10 @@ +@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -3194,7 +3194,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2150,11 +2150,11 @@ +@@ -2150,11 +2150,11 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} @@ -3208,7 +3208,7 @@ diff --git a/client.js b/client.js .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} -@@ -2163,6 +2163,8 @@ +@@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -3217,7 +3217,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2197,7 +2197,7 @@ +@@ -2197,7 +2197,7 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} @@ -3226,7 +3226,7 @@ diff --git a/client.js b/client.js .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} -@@ -2237,9 +2237,23 @@ +@@ -2237,9 +2237,23 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ @@ -3251,7 +3251,7 @@ diff --git a/client.js b/client.js .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} -@@ -2255,6 +2255,7 @@ +@@ -2255,6 +2255,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -3259,7 +3259,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2288,13 +2288,60 @@ +@@ -2288,13 +2288,60 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -3320,7 +3320,7 @@ diff --git a/client.js b/client.js /* Settings → Connectors. */ .bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -@@ -2308,8 +2308,17 @@ +@@ -2308,8 +2308,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -3338,7 +3338,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2361,7 +2361,7 @@ +@@ -2361,7 +2361,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} @@ -3347,7 +3347,7 @@ diff --git a/client.js b/client.js .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} -@@ -2380,7 +2380,7 @@ +@@ -2380,7 +2380,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3356,7 +3356,7 @@ diff --git a/client.js b/client.js .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} -@@ -2390,7 +2390,7 @@ +@@ -2390,7 +2390,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} @@ -3365,7 +3365,7 @@ diff --git a/client.js b/client.js .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} -@@ -2427,7 +2427,8 @@ +@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -3375,7 +3375,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2494,7 +2494,7 @@ +@@ -2494,7 +2494,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} @@ -3384,7 +3384,7 @@ diff --git a/client.js b/client.js .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} -@@ -2597,9 +2597,20 @@ +@@ -2597,9 +2597,20 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} `; var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} @@ -3407,7 +3407,7 @@ diff --git a/client.js b/client.js .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} .bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -@@ -2612,7 +2612,6 @@ +@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -3415,7 +3415,7 @@ diff --git a/client.js b/client.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -2622,21 +2622,48 @@ +@@ -2622,21 +2622,48 @@ var CONVERSATION_CSS = ` [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -3472,7 +3472,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2646,8 +2646,19 @@ +@@ -2646,8 +2646,19 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} [data-chat-running]>span:not([role=status]){display:none!important} [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} @@ -3492,7 +3492,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +2679,8 @@ +@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -3502,7 +3502,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +2691,8 @@ +@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} @@ -3511,7 +3511,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,7 +2722,9 @@ +@@ -2722,7 +2722,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} body[data-bt-room] .bt-voice{display:none!important} @@ -3521,7 +3521,7 @@ diff --git a/client.js b/client.js .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} .bt-q{order:1} -@@ -2776,6 +2776,7 @@ +@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} @@ -3529,7 +3529,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2784,10 +2784,10 @@ +@@ -2784,10 +2784,10 @@ div:has(> nav [class*="_marks"]){display:none!important} .bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} .bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} `; @@ -3542,7 +3542,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2807,9 +2807,9 @@ +@@ -2807,9 +2807,9 @@ function sheetClassNames(sheet) { } return names; } @@ -3554,7 +3554,7 @@ diff --git a/client.js b/client.js if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -@@ -2818,7 +2818,7 @@ +@@ -2818,7 +2818,7 @@ function compileFragmentSelectors(text, names) { return compiled.get(fragment); }); } @@ -3563,7 +3563,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -2832,7 +2832,7 @@ +@@ -2832,7 +2832,7 @@ function installCompiledStyles(text, tag) { } for (const name of list) names.add(name); } @@ -3572,7 +3572,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -2855,226 +2855,204 @@ +@@ -2855,226 +2855,204 @@ function installCompiledStyles(text, tag) { }; } @@ -3692,7 +3692,28 @@ diff --git a/client.js b/client.js +function isAutoFaceTitle(name) { + const n = String(name ?? "").trim(); + return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +-} +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function ownersFromMembers(members) { + const rows = []; + const seen = /* @__PURE__ */ new Set(); @@ -3703,22 +3724,42 @@ diff --git a/client.js b/client.js + seen.add(key); + const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); + rows.push({ owner, sort: handle || key }); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); - return (0, import_react2.createElement)( - "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) - ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); @@ -3753,14 +3794,7 @@ diff --git a/client.js b/client.js + if (people2.length) bits.push(people2.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); - } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -3782,26 +3816,12 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById?.[sessionId]; + if (bot?.remote) { + return friendBotTitle(bot); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); + if (room2) return roomTitle(room2); + return bot?.name || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -3831,25 +3851,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3952,7 +3954,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3086,6 +3086,7 @@ +@@ -3086,6 +3086,7 @@ var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]) var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); @@ -3960,7 +3962,7 @@ diff --git a/client.js b/client.js var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); -@@ -3093,14 +3093,14 @@ +@@ -3093,14 +3093,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -3979,7 +3981,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +3112,48 @@ +@@ -3112,21 +3112,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -4031,7 +4033,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3146,10 @@ +@@ -3146,10 +3146,10 @@ function useOptionKeys(count, onPick, ownRef) { }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -4046,7 +4048,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3167,7 @@ +@@ -3167,7 +3167,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -4055,7 +4057,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3186,7 @@ +@@ -3186,7 +3186,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -4064,7 +4066,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +3196,36 @@ +@@ -3196,36 +3196,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -4114,7 +4116,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3235,24 @@ +@@ -3235,24 +3235,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised submit(); } }, @@ -4149,7 +4151,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +3262,29 @@ +@@ -3262,29 +3262,29 @@ function SettledQuestion({ question, answer }) { const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -4190,7 +4192,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +3292,8 @@ +@@ -3292,8 +3292,8 @@ function SettledQuestion({ question, answer }) { function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -4201,7 +4203,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3305,10 @@ +@@ -3305,10 +3305,10 @@ function QuestionCard({ matched }) { } pending.answer({ answers: next }); }; @@ -4214,7 +4216,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3318,10 @@ +@@ -3318,10 +3318,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -4227,7 +4229,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3329,16 @@ +@@ -3329,16 +3329,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -4247,7 +4249,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +3346,37 @@ +@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { } // src/client/secret-card.js @@ -4297,7 +4299,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,42 +3401,42 @@ +@@ -3401,42 +3401,42 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { }); } function ScopeSwitch({ value, onChange, bot }) { @@ -4357,7 +4359,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3452,73 @@ +@@ -3452,73 +3452,73 @@ function SecretCard({ bot, request, roster, actions }) { if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -4457,7 +4459,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3531,46 @@ +@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -4520,7 +4522,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3583,66 @@ +@@ -3583,66 +3583,66 @@ function ValueEditor({ secret, onSave }) { setValue(next); setReplaced(false); }; @@ -4613,7 +4615,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +3653,43 @@ +@@ -3653,43 +3653,43 @@ function AddSecret({ actions, run, onDone }) { } }); }; @@ -4675,7 +4677,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +3756,144 @@ +@@ -3756,6 +3756,144 @@ var SECRET_CSS = ` @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -4820,7 +4822,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +3767,7 @@ +@@ -3767,7 +3767,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4829,7 +4831,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,29 +3780,29 @@ +@@ -3780,29 +3780,29 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4867,7 +4869,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +3813,22 @@ +@@ -3813,9 +3813,22 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, }) ); } @@ -4891,7 +4893,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3835,12 +3835,12 @@ +@@ -3835,12 +3835,12 @@ function useDismiss(open, close, refs) { }; }, [open]); } @@ -4910,7 +4912,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3854,36 +3854,36 @@ +@@ -3854,36 +3854,36 @@ function MessageTools({ text, name, sessionId, react, actions }) { setPanel(kind); }; const copy2 = () => { @@ -4958,7 +4960,7 @@ diff --git a/client.js b/client.js "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3891,12 +3891,40 @@ +@@ -3891,12 +3891,40 @@ function MessageTools({ text, name, sessionId, react, actions }) { ), document.body) : null ); } @@ -5003,7 +5005,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +3904,33 @@ +@@ -3904,19 +3904,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -5039,7 +5041,7 @@ diff --git a/client.js b/client.js const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +3924,14 @@ +@@ -3924,13 +3924,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; @@ -5057,7 +5059,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,230 @@ +@@ -3940,61 +3940,230 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -5123,7 +5125,9 @@ diff --git a/client.js b/client.js }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text2, bot) { @@ -5136,9 +5140,7 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -5311,7 +5313,7 @@ diff --git a/client.js b/client.js ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4005,14 @@ +@@ -4005,14 +4005,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { const target = other && other.id !== self?.id ? other : self; if (!target) return null; const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); @@ -5329,7 +5331,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4021,11 @@ +@@ -4021,11 +4021,11 @@ var scrollParent = (node) => { return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -5346,7 +5348,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4041,19 @@ +@@ -4041,19 +4041,19 @@ function EarlierParts({ sessionId, roster, actions }) { if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -5369,7 +5371,7 @@ diff --git a/client.js b/client.js const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4069,35 @@ +@@ -4069,35 +4069,35 @@ function EarlierParts({ sessionId, roster, actions }) { if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -5416,7 +5418,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4105,7 @@ +@@ -4105,7 +4105,7 @@ function EarlierParts({ sessionId, roster, actions }) { return null; } }), @@ -5425,7 +5427,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4116,7 @@ +@@ -4116,7 +4116,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -5434,7 +5436,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4135,31 @@ +@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -5473,7 +5475,7 @@ diff --git a/client.js b/client.js const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4165,7 @@ +@@ -4165,7 +4165,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -5482,7 +5484,7 @@ diff --git a/client.js b/client.js prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4174,24 @@ +@@ -4174,24 +4174,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -5512,7 +5514,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4199,65 @@ +@@ -4199,11 +4199,66 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -5554,7 +5556,8 @@ diff --git a/client.js b/client.js + "attach_capability", + "detach_capability", + "track_links", -+ "project_state" ++ "project_state", ++ "project_digest" +]); +var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); @@ -5579,7 +5582,7 @@ diff --git a/client.js b/client.js var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); -@@ -4233,10 +4233,19 @@ +@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; @@ -5600,7 +5603,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4250,15 @@ +@@ -4250,15 +4250,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -5621,7 +5624,7 @@ diff --git a/client.js b/client.js } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +4320,22 @@ +@@ -4320,19 +4320,22 @@ function toolActivity(name, arg, findBot, self) { } function workPhrase(tool) { if (!tool) return t("Thinking about it"); @@ -5649,7 +5652,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +4341,10 @@ +@@ -4341,10 +4341,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -5662,7 +5665,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +4362,89 @@ +@@ -4362,71 +4362,89 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies = []; } } @@ -5783,7 +5786,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +4435,52 @@ +@@ -4435,52 +4435,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -5852,7 +5855,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +4492,90 @@ +@@ -4492,64 +4492,90 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -5961,7 +5964,7 @@ diff --git a/client.js b/client.js var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +4559,69 @@ +@@ -4559,9 +4559,69 @@ function elapsedLabel(ms) { const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -6034,7 +6037,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +4569,7 @@ +@@ -4569,7 +4569,7 @@ function useRunningHost(active) { let frame = 0; const find = () => { frame = 0; @@ -6043,7 +6046,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +4581,13 @@ +@@ -4581,13 +4581,13 @@ function useRunningHost(active) { cancelAnimationFrame(frame); observer.disconnect(); }; @@ -6060,7 +6063,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +4638,31 @@ +@@ -4638,21 +4638,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; @@ -6099,7 +6102,7 @@ diff --git a/client.js b/client.js if (wantKey === null || shown?.key === wantKey) return void 0; const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,26 +4663,31 @@ +@@ -4663,26 +4663,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -6143,7 +6146,7 @@ diff --git a/client.js b/client.js setShown(null); const before = shift.current; shift.current = null; -@@ -4691,31 +4691,37 @@ +@@ -4691,31 +4691,37 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const delta = before - scroller.scrollTop; if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); }, [phase]); @@ -6193,10 +6196,10 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,1687 @@ +@@ -4731,49 +4731,1687 @@ function showsDshRow(dsh, app) { return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } -+ + +// src/client/overlay-hooks.js +var import_react11 = require("react"); +var escapeLayers = []; @@ -7829,7 +7832,7 @@ diff --git a/client.js b/client.js + .bt-board-column{min-width:0} +} +`; - ++ // src/client/sidebar.js -var import_react8 = require("react"); +var import_react16 = require("react"); @@ -7894,7 +7897,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4785,131 @@ +@@ -4785,55 +4785,131 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -8041,7 +8044,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +4841,20 @@ +@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -8070,7 +8073,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +4875,10 @@ +@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { } })(); }; @@ -8083,7 +8086,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +4891,7 @@ +@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { }) ); } @@ -8092,7 +8095,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +4905,8 @@ +@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8103,7 +8106,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +4941,10 @@ +@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -8116,7 +8119,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +4959,30 @@ +@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8158,7 +8161,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +4996,8 @@ +@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -8169,7 +8172,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5014,44 @@ +@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { tops.current = { layout, map: next }; }, [layout, key]); } @@ -8215,7 +8218,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +5025,26 @@ +@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -8246,7 +8249,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5045,42 @@ +@@ -5045,39 +5045,42 @@ function TeamSidebar(props) { event.preventDefault(); actions.openPalette(event.key); }; @@ -8309,7 +8312,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +5116,14 @@ +@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -8329,7 +8332,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5133,21 @@ +@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -8362,7 +8365,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5155,17 @@ +@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -8387,7 +8390,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +5176,25 @@ +@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { ), document.body) : null ); } @@ -8459,7 +8462,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5259,18 @@ +@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { else openHarnessSettings(); }); } @@ -8478,7 +8481,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +5288,32 @@ +@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -8519,7 +8522,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +5341,32 @@ +@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -8564,7 +8567,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +5372,7 @@ +@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -8573,7 +8576,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5382,8 @@ +@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -8584,7 +8587,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +5397,40 @@ +@@ -5397,40 +5397,40 @@ function useLandOnMainBot(roster, useSessions, actions) { return void 0; } const last = lastBot.current; @@ -8635,7 +8638,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5456,7 +5456,7 @@ +@@ -5456,7 +5456,7 @@ var SIDEBAR_CSS = ` .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} @@ -8644,7 +8647,7 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5491,18 +5491,140 @@ +@@ -5491,18 +5491,140 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -8790,7 +8793,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,19 +5512,19 @@ +@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { @@ -8816,7 +8819,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); const firstTurns = useSessions((list) => { -@@ -5537,7 +5537,7 @@ +@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -8825,7 +8828,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,41 @@ +@@ -5548,52 +5548,41 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -8875,14 +8878,15 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) +- ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + bot ? (0, import_react18.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react18.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react18.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react18.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react18.createElement)(GoArrow)) - ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ ), + (0, import_react18.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + (0, import_react18.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react18.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), @@ -8897,7 +8901,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +5642,7 @@ +@@ -5642,7 +5642,7 @@ function useTranscriptView() { }, []); return view; } @@ -8906,7 +8910,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +5658,22 @@ +@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -8934,7 +8938,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +5677,16 @@ +@@ -5677,16 +5677,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -8955,7 +8959,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +5694,7 @@ +@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { event.stopPropagation(); onDismiss(); } @@ -8964,7 +8968,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +5703,9 @@ +@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -8977,7 +8981,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5714,135 @@ +@@ -5714,19 +5714,135 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ); } @@ -9121,7 +9125,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5745,10 +5745,10 @@ +@@ -5745,10 +5745,10 @@ function useDictation(onText) { let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -9135,7 +9139,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +5774,38 @@ +@@ -5774,19 +5774,38 @@ function useDictation(onText) { const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -9179,7 +9183,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +5797,10 @@ +@@ -5797,10 +5797,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -9193,7 +9197,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +5808,16 @@ +@@ -5808,16 +5808,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -9214,7 +9218,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5825,17 @@ +@@ -5825,17 +5825,17 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -9237,7 +9241,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +5876,10 @@ +@@ -5876,10 +5876,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -9251,7 +9255,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +5889,8 @@ +@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -9262,7 +9266,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5900,9 @@ +@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -9275,7 +9279,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,57 @@ +@@ -5922,15 +5922,57 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -9337,7 +9341,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5959,31 @@ +@@ -5959,14 +5959,31 @@ function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -9373,7 +9377,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5995,83 @@ +@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { return [bots, chats]; } @@ -9457,7 +9461,7 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6008,8 +6008,8 @@ +@@ -6008,8 +6008,8 @@ async function speechCatalog(speech) { function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -9468,7 +9472,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -6030,9 +6030,9 @@ +@@ -6030,9 +6030,9 @@ function waveBytes(samples, rate = 16e3) { return bytes; } function base64Of(bytes) { @@ -9481,7 +9485,7 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -6121,233 +6121,9 @@ +@@ -6121,233 +6121,9 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { }; } @@ -9717,7 +9721,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS = 120; function groupModels(models) { const groups = /* @__PURE__ */ new Map(); -@@ -6367,17 +6367,10 @@ +@@ -6367,17 +6367,10 @@ function groupModels(models) { }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -9737,7 +9741,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6402,12 @@ +@@ -6402,12 +6402,12 @@ function effortRows(reasoning) { ]; } function EffortRows({ reasoning, current, onPick }) { @@ -9755,7 +9759,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +6416,17 @@ +@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -9779,7 +9783,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6436,20 @@ +@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { title: model.ref, onClick: () => onPick(model.ref) }, @@ -9808,7 +9812,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +6484,7 @@ +@@ -6484,6 +6484,7 @@ function useAnchoredMenu() { return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -9816,7 +9820,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +6511,10 @@ +@@ -6511,10 +6511,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -9830,7 +9834,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +6527,38 @@ +@@ -6527,37 +6527,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -9883,7 +9887,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6569,13 +6569,13 @@ +@@ -6569,13 +6569,13 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -9902,7 +9906,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6584,115 @@ +@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -10034,7 +10038,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,16 @@ +@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { }); } var MODEL_CSS = ` @@ -10051,7 +10055,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6675,96 @@ +@@ -6675,18 +6675,96 @@ var MODEL_CSS = ` @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -10156,7 +10160,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +6719,35 @@ +@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -10208,7 +10212,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +6759,33 @@ +@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -10258,7 +10262,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +6798,19 @@ +@@ -6798,19 +6798,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } title: value, onClick: toggle }, @@ -10284,7 +10288,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6830,8 @@ +@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` `; // src/client/schedules.js @@ -10295,7 +10299,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +6839,8 @@ +@@ -6839,8 +6839,8 @@ function publish(view) { for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -10306,7 +10310,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +6848,7 @@ +@@ -6848,7 +6848,7 @@ function useSchedules(actions) { if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -10315,7 +10319,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +6873,8 @@ +@@ -6873,7 +6873,8 @@ function weekdayNames(days) { function everyText(seconds) { if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); @@ -10325,7 +10329,7 @@ diff --git a/client.js b/client.js } function timingText(task) { const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -@@ -6887,8 +6887,8 @@ +@@ -6887,8 +6887,8 @@ function timingText(task) { return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -10336,7 +10340,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -6896,11 +6896,25 @@ +@@ -6896,11 +6896,25 @@ function timingText(task) { return ""; } } @@ -10367,7 +10371,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,49 @@ +@@ -6913,20 +6913,49 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -10426,7 +10430,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +6934,7 @@ +@@ -6934,7 +6934,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -10435,7 +10439,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6944,27 +6944,49 @@ +@@ -6944,27 +6944,49 @@ function inAnHour() { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; @@ -10502,7 +10506,7 @@ diff --git a/client.js b/client.js setBusy(true); setError(""); let when3; -@@ -6975,151 +6975,298 @@ +@@ -6975,151 +6975,298 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { setBusy(false); return; } @@ -10863,7 +10867,7 @@ diff --git a/client.js b/client.js .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -@@ -7127,6 +7127,12 @@ +@@ -7127,6 +7127,12 @@ var SCHEDULE_CSS = ` .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -10876,7 +10880,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,12 +7158,12 @@ +@@ -7158,12 +7158,12 @@ var SCHEDULE_CSS = ` `; // src/client/capability-settings.js @@ -10892,7 +10896,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -7172,11 +7172,11 @@ +@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -10907,7 +10911,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7188,7 @@ +@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -10916,7 +10920,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +7199,7 @@ +@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -10925,7 +10929,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +7219,95 @@ +@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -11061,7 +11065,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7330,20 @@ +@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -11089,7 +11093,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ +@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -11102,7 +11106,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ +@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow ); } @@ -11160,7 +11164,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +7384,13 @@ +@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -11177,7 +11181,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +7404,16 @@ +@@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11200,7 +11204,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7428,45 @@ +@@ -7428,45 +7428,45 @@ function ago(at, now) { return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -11265,7 +11269,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7478,9 @@ +@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { return groups; }, []); function EntryRow({ entry, remove }) { @@ -11278,7 +11282,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +7495,16 @@ +@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -11300,7 +11304,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +7512,7 @@ +@@ -7512,7 +7512,7 @@ function EntryRow({ entry, remove }) { "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -11309,7 +11313,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,82 +7521,82 @@ +@@ -7521,82 +7521,82 @@ function Entries({ bot, view, remove }) { { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -11427,7 +11431,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +7606,9 @@ +@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { }, [botId]); return { view, error, load, show }; } @@ -11440,7 +11444,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +7616,18 @@ +@@ -7616,18 +7616,18 @@ function useNow(every) { } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -11469,7 +11473,7 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7671,7 +7671,7 @@ +@@ -7671,7 +7671,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { }; const ask = async (event) => { event.preventDefault(); @@ -11478,7 +11482,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,65 +7689,65 @@ +@@ -7689,65 +7689,65 @@ function MemoryDialog({ bot, actions, leaving = false }) { const when3 = view?.summary ? ago(view.summary.at, now) : null; const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; const busy = answer?.pending === true; @@ -11571,7 +11575,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +7755,40 @@ +@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { ); } @@ -11613,7 +11617,7 @@ diff --git a/client.js b/client.js var capitalize = (word) => word[0].toUpperCase() + word.slice(1); async function avatarImage(file) { if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -@@ -7780,8 +7780,8 @@ +@@ -7780,8 +7780,8 @@ async function avatarImage(file) { throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -11624,7 +11628,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +7792,7 @@ +@@ -7792,7 +7792,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -11633,7 +11637,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +7813,29 @@ +@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu }); } function Row({ label, hint, stack = false, children }) { @@ -11672,7 +11676,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ +@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -11700,7 +11704,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +7869,8 @@ +@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -11711,7 +11715,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +7878,19 @@ +@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -11737,7 +11741,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +7908,7 @@ +@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -11746,7 +11750,7 @@ diff --git a/client.js b/client.js if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7922,32 @@ +@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -11787,7 +11791,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +7963,15 @@ +@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { upload(event.dataTransfer.files?.[0]); } }, @@ -11809,7 +11813,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +7982,27 @@ +@@ -7982,26 +7982,27 @@ function AvatarEditor({ bot, actions, run, onClose }) { function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11845,7 +11849,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,112 +8011,116 @@ +@@ -8011,112 +8011,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -12011,7 +12015,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8130,9 +8130,17 @@ +@@ -8130,9 +8130,17 @@ var BOT_SETTINGS_CSS = ` .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} @@ -12030,7 +12034,7 @@ diff --git a/client.js b/client.js .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -@@ -8153,8 +8153,659 @@ +@@ -8153,8 +8153,659 @@ label.bt-bs-row{cursor:text} .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} @@ -12042,8 +12046,8 @@ diff --git a/client.js b/client.js + .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} -+`; -+ + `; + +// src/client/group-settings.js +var import_react29 = require("react"); +var ROOM_MODES = [ @@ -12486,8 +12490,8 @@ diff --git a/client.js b/client.js +.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out} +.bt-bs-stack .bt-seg{align-self:stretch} +@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} - `; - ++`; ++ +// src/client/palette.js +var import_react30 = require("react"); +var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); @@ -12690,7 +12694,7 @@ diff --git a/client.js b/client.js // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); -@@ -8165,36 +8165,48 @@ +@@ -8165,36 +8165,48 @@ function freshBotName(roster, base = "New Bot") { } function startModel(roster) { const models = roster.models ?? []; @@ -12754,7 +12758,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +8206,8 @@ +@@ -8206,8 +8206,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setError(""); setBusy(true); try { @@ -12765,7 +12769,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +8219,25 @@ +@@ -8219,25 +8219,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12799,7 +12803,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8249,27 @@ +@@ -8249,27 +8249,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setLook((current) => ({ ...current, color })); } }), @@ -12835,7 +12839,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +8284,113 @@ +@@ -8284,75 +8284,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -12979,7 +12983,7 @@ diff --git a/client.js b/client.js const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); -@@ -8386,71 +8386,104 @@ +@@ -8386,71 +8386,104 @@ function NewChat({ mode, roster, actions }) { }; const created = (bot) => { close(); @@ -13104,7 +13108,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +8467,41 @@ +@@ -8467,33 +8467,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -13161,7 +13165,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +8517,20 @@ +@@ -8517,20 +8517,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -13187,7 +13191,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,36 @@ +@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -13233,7 +13237,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +8569,7 @@ +@@ -8569,7 +8569,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -13242,7 +13246,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,41 @@ +@@ -8577,18 +8577,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js @@ -13288,7 +13292,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,49 +8612,42 @@ +@@ -8612,49 +8612,42 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -13352,7 +13356,7 @@ diff --git a/client.js b/client.js var VOICE_LABELS = { listening: "Listening", thinking: "Thinking", -@@ -8665,14 +8665,14 @@ +@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -13370,7 +13374,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +8716,18 @@ +@@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -13400,7 +13404,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8735,24 +8735,24 @@ +@@ -8735,24 +8735,24 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } actions.closeVoice(); }; useEscape(close); @@ -13433,7 +13437,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +8767,16 @@ +@@ -8767,16 +8767,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }; }, [speech]); const listen = phase === "listening"; @@ -13453,7 +13457,7 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8789,9 +8789,9 @@ +@@ -8789,9 +8789,9 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -13465,7 +13469,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -8838,12 +8838,12 @@ +@@ -8838,12 +8838,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -13482,7 +13486,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -8858,7 +8858,7 @@ +@@ -8858,7 +8858,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -13491,7 +13495,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,291 +8875,605 @@ +@@ -8875,291 +8875,605 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -13926,10 +13930,54 @@ diff --git a/client.js b/client.js - { title: t("Sidebar") }, - (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), - (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react23.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) +- ), +- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) +- ) + }) -+ ) -+ ); -+} + ) + ); + } +-function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { +- const [admin, setAdmin] = (0, import_react23.useState)(null); +- const [members, setMembers] = (0, import_react23.useState)([]); +- const [name, setName] = (0, import_react23.useState)(""); +- const [busy, setBusy] = (0, import_react23.useState)(false); +- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const pickAdmin = (id) => { +- setAdmin(id); +- setMembers((list) => list.filter((item) => item !== id)); +- }; +- const create = async () => { +- if (busy || admin === null || members.length === 0) return; +- setBusy(true); +- let room2; +- const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { +- room2 = value; +- })); +- if (done && room2) onCreated(room2); +- else setBusy(false); +- }; +- const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { +- key: bot.id, +- type: "button", +- role, +- className: "bt-ng-chip", +- "aria-checked": on, +- onClick +- }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); +- return (0, import_react23.createElement)( +function Row2({ item, source, onOpen, onRename, onDelete }) { + return (0, import_react35.createElement)( + "button", @@ -13945,20 +13993,7 @@ diff --git a/client.js b/client.js + { className: "bt-set-copy" }, + (0, import_react35.createElement)("span", null, item.name), + (0, import_react35.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "") - ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Manage") }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react23.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) -- ), -- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) ++ ), + source === "own" ? (0, import_react35.createElement)( + "span", + { className: "bt-set-actions" }, @@ -13997,7 +14032,9 @@ diff --git a/client.js b/client.js + const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id)); + const showUse = opened?.source === "factory" || opened?.fromFactory !== void 0; + return (0, import_react35.createElement)( -+ "div", + "div", +- { className: "bt-set-card bt-ng" }, +- (0, import_react23.createElement)( + { className: "bt-wf-page" }, + (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, t("A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.")), + error ? (0, import_react35.createElement)( @@ -14036,14 +14073,18 @@ diff --git a/client.js b/client.js + { className: "bt-set-card" }, + yours.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-set-row" }, (0, import_react35.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react35.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) + ) - ) -- ) ++ ) + ) : null, + graph ? (0, import_react35.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), +- (0, import_react23.createElement)( + { className: "bt-wf-editor" }, + (0, import_react35.createElement)( -+ "div", + "div", +- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, +- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) + { className: "bt-wf-actions" }, + (0, import_react35.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), + showUse ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, @@ -14058,51 +14099,17 @@ diff --git a/client.js b/client.js + editing: opened?.source === "own" && editing === true + }) + ) : null - ); - } --function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { -- const [admin, setAdmin] = (0, import_react23.useState)(null); -- const [members, setMembers] = (0, import_react23.useState)([]); -- const [name, setName] = (0, import_react23.useState)(""); -- const [busy, setBusy] = (0, import_react23.useState)(false); -- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const pickAdmin = (id) => { -- setAdmin(id); -- setMembers((list) => list.filter((item) => item !== id)); -- }; -- const create = async () => { -- if (busy || admin === null || members.length === 0) return; -- setBusy(true); -- let room2; -- const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { -- room2 = value; -- })); -- if (done && room2) onCreated(room2); -- else setBusy(false); -- }; -- const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { -- key: bot.id, -- type: "button", -- role, -- className: "bt-ng-chip", -- "aria-checked": on, -- onClick -- }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); -- return (0, import_react23.createElement)( ++ ); ++} +function RenameModal({ workflow, onClose, onConfirm }) { + const [name, setName] = (0, import_react35.useState)(workflow?.name ?? ""); + return (0, import_react35.createElement)( - "div", -- { className: "bt-set-card bt-ng" }, -- (0, import_react23.createElement)( ++ "div", + { className: "bt-modal-mask", onClick: (event) => { + if (event.target === event.currentTarget) onClose?.(); + } }, + (0, import_react35.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), -- (0, import_react23.createElement)( ++ "div", + { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") }, + (0, import_react35.createElement)("h3", null, t("Rename workflow")), + (0, import_react35.createElement)( @@ -14112,15 +14119,17 @@ diff --git a/client.js b/client.js + (0, import_react35.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true }) + ), + (0, import_react35.createElement)( - "div", -- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, -- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) ++ "div", + { className: "bt-modal-actions" }, + (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), + (0, import_react35.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename")) ) - ), - admin === null ? null : (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), +- (0, import_react23.createElement)( + ) + ); +} @@ -14131,10 +14140,7 @@ diff --git a/client.js b/client.js + if (event.target === event.currentTarget) onClose?.(); + } }, + (0, import_react35.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), -- (0, import_react23.createElement)( ++ "div", + { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") }, + (0, import_react35.createElement)("h3", null, t("Delete workflow")), + (0, import_react35.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })), @@ -14333,7 +14339,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +9277,13 @@ +@@ -9277,13 +9277,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { } return result; } @@ -14351,7 +14357,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9294,11 @@ +@@ -9294,11 +9294,11 @@ function percent(part, whole) { } // src/client/usage-page.js @@ -14365,7 +14371,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +9341,9 @@ +@@ -9341,9 +9341,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -14378,7 +14384,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9363,9 @@ +@@ -9363,9 +9363,9 @@ function useTween(value) { }, [value]); return shown; } @@ -14390,7 +14396,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +9382,7 @@ +@@ -9382,7 +9382,7 @@ function ownerFace(owner, roster) { sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -14399,7 +14405,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9392,7 @@ +@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -14408,7 +14414,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +9411,33 @@ +@@ -9411,33 +9411,33 @@ function kindLabel(kind, botFocus) { return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -14453,7 +14459,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +9455,83 @@ +@@ -9455,83 +9455,83 @@ function niceTop(max) { return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -14565,7 +14571,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +9540,69 @@ +@@ -9540,69 +9540,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -14663,7 +14669,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +9628,17 @@ +@@ -9628,17 +9628,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -14684,7 +14690,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +9654,15 @@ +@@ -9654,15 +9654,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -14706,7 +14712,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +9671,30 @@ +@@ -9671,30 +9671,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -14745,7 +14751,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +9710,13 @@ +@@ -9710,13 +9710,13 @@ function Heat({ view, keep, paint, index }) { for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -14761,7 +14767,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +9724,7 @@ +@@ -9724,7 +9724,7 @@ function Heat({ view, keep, paint, index }) { "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -14770,7 +14776,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9733,61 @@ +@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -14858,7 +14864,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +9797,7 @@ +@@ -9797,7 +9797,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; @@ -14867,7 +14873,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +9814,7 @@ +@@ -9814,7 +9814,7 @@ function UsagePage({ roster, actions, focusId }) { }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -14876,7 +14882,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +9823,7 @@ +@@ -9823,7 +9823,7 @@ function UsagePage({ roster, actions, focusId }) { const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -14885,7 +14891,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +9871,23 @@ +@@ -9871,23 +9871,23 @@ function UsagePage({ roster, actions, focusId }) { }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -14917,7 +14923,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9912,7 @@ +@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -14926,7 +14932,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +9924,7 @@ +@@ -9924,7 +9924,7 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -14935,7 +14941,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +9936,30 @@ +@@ -9936,30 +9936,30 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -14976,7 +14982,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9968,39 @@ +@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -15030,7 +15036,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +10150,216 @@ +@@ -10150,33 +10150,216 @@ button.bt-us-row:hover{background:var(--bt-hover)} @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -15258,7 +15264,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +10184,195 @@ +@@ -10184,83 +10184,195 @@ function ConnectorRow({ connector, actions }) { after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -15369,21 +15375,23 @@ diff --git a/client.js b/client.js + on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form" }, + "div", + { className: "bt-connector-form" }, +- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), +- (0, import_react25.createElement)( + (0, import_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), + (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, + "div", + { className: "bt-connector-form-row" }, +- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react37.createElement)("span", { className: "bt-connector-link" }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + ) + ) : null, + !on && form ? (0, import_react37.createElement)( - "div", - { className: "bt-connector-form" }, -- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), -- (0, import_react25.createElement)( ++ "div", ++ { className: "bt-connector-form" }, + channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { + key: entry.id, + type: "button", @@ -15405,10 +15413,8 @@ diff --git a/client.js b/client.js + })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, + (0, import_react37.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ "div", ++ { className: "bt-connector-form-row" }, + (0, import_react37.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, @@ -15484,7 +15490,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +10270,18 @@ +@@ -10270,18 +10270,18 @@ function entrySummary(entry) { if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -15510,7 +15516,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10289,7 @@ +@@ -10289,7 +10289,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -15519,7 +15525,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,8 +10307,8 @@ +@@ -10307,8 +10307,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { if (!inline && !copied) setPreview(true); })); }; @@ -15530,7 +15536,7 @@ diff --git a/client.js b/client.js setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); -@@ -10317,59 +10317,59 @@ +@@ -10317,59 +10317,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -15618,7 +15624,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10377,8 @@ +@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -15629,7 +15635,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10389,16 @@ +@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { }; }, [toast?.token]); if (!toast) return null; @@ -15651,7 +15657,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +10416,56 @@ +@@ -10416,47 +10416,56 @@ var FEEDBACK_CSS = ` .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -15722,7 +15728,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +10466,72 @@ +@@ -10466,87 +10466,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { live = false; }; }, []); @@ -15833,7 +15839,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10569,59 @@ +@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -15911,7 +15917,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,86 @@ +@@ -10626,75 +10626,86 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -16021,7 +16027,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +10706,69 @@ +@@ -10706,69 +10706,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { @@ -16121,7 +16127,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10779,113 @@ +@@ -10779,104 +10779,113 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -16276,7 +16282,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +10884,22 @@ +@@ -10884,13 +10884,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -16301,7 +16307,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +10901,22 @@ +@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -16330,7 +16336,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10923,14 @@ +@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16349,7 +16355,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10952,14 @@ +@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -16369,7 +16375,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,16 +10968,31 @@ +@@ -10968,16 +10968,31 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -16402,7 +16408,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11038,244 +11038,8 @@ +@@ -11038,244 +11038,8 @@ function migrateStorage(storage = globalThis.localStorage) { } } @@ -16648,7 +16654,7 @@ diff --git a/client.js b/client.js var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +11289,10 @@ +@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -16661,7 +16667,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11302,14 @@ +@@ -11302,14 +11302,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -16680,7 +16686,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11323,42 @@ +@@ -11323,42 +11323,42 @@ function PermissionRow({ bot, roster, actions, run }) { function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -16738,7 +16744,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,195 @@ +@@ -11367,87 +11367,195 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -16979,7 +16985,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11457,7 +11457,7 @@ +@@ -11457,7 +11457,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -16988,7 +16994,7 @@ diff --git a/client.js b/client.js const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -11467,41 +11467,67 @@ +@@ -11467,41 +11467,67 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a return false; }); const share = () => { @@ -17072,7 +17078,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +11515,12 @@ +@@ -11515,12 +11515,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -17089,7 +17095,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,98 +11535,97 @@ +@@ -11535,98 +11535,97 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -17233,7 +17239,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +11674,11 @@ +@@ -11674,11 +11674,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -17250,7 +17256,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +11707,7 @@ +@@ -11707,7 +11707,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); @@ -17259,7 +17265,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +11721,13 @@ +@@ -11721,13 +11721,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); @@ -17276,7 +17282,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +11737,13 @@ +@@ -11737,13 +11737,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, @@ -17293,7 +17299,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +11764,9 @@ +@@ -11764,9 +11764,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, @@ -17306,7 +17312,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +11778,20 @@ +@@ -11778,20 +11778,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), @@ -17334,7 +17340,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,43 +11817,43 @@ +@@ -11817,43 +11817,43 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -17391,7 +17397,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +11877,25 @@ +@@ -11877,25 +11877,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), @@ -17427,7 +17433,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11907,41 @@ +@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -17485,7 +17491,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +11955,28 @@ +@@ -11955,28 +11955,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); @@ -17521,7 +17527,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12013,7 @@ +@@ -12013,7 +12013,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } @@ -17530,7 +17536,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12096,7 @@ +@@ -12096,7 +12096,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -17539,7 +17545,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +12113,15 @@ +@@ -12113,15 +12113,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, @@ -17560,7 +17566,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,9 +12217,79 @@ +@@ -12217,9 +12217,79 @@ var BROWSER_CSS = ` @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -17642,7 +17648,7 @@ diff --git a/client.js b/client.js var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) { -@@ -12227,15 +12227,15 @@ +@@ -12227,15 +12227,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -17662,7 +17668,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +12245,26 @@ +@@ -12245,19 +12245,26 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -17699,7 +17705,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +12294,19 @@ +@@ -12294,19 +12294,19 @@ function readPage(limits) { const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; @@ -17726,7 +17732,7 @@ diff --git a/client.js b/client.js const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; -@@ -12340,8 +12340,8 @@ +@@ -12340,8 +12340,8 @@ function readPage(limits) { items.push(item); return { item, number: items.length, secret }; }; @@ -17737,7 +17743,7 @@ diff --git a/client.js b/client.js ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); -@@ -12372,7 +12372,7 @@ +@@ -12372,7 +12372,7 @@ ${fence}`; const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -17746,7 +17752,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12398,11 @@ +@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -17760,7 +17766,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +12410,18 @@ +@@ -12410,18 +12410,18 @@ ${children(cell, ctx)} for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -17784,7 +17790,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12480,9 +12480,9 @@ +@@ -12480,9 +12480,9 @@ ${body2 || "(no text)"} if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -17797,7 +17803,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -12552,7 +12552,7 @@ +@@ -12552,7 +12552,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} } if (tag === "blockquote") { const quoted = block(body2); @@ -17806,7 +17812,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -12570,12 +12570,12 @@ +@@ -12570,12 +12570,12 @@ ${marked} return marked; } const body = document.body; @@ -17823,7 +17829,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -12585,7 +12585,7 @@ +@@ -12585,7 +12585,7 @@ ${marked} description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -17832,7 +17838,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -12613,15 +12613,15 @@ +@@ -12613,15 +12613,15 @@ var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.in var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { @@ -17856,10 +17862,10 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +13052,285 @@ +@@ -13052,11 +13052,285 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; -+} -+ + } + +// src/client/mobile.js +var MOBILE_MAX_WIDTH = 640; +var MOBILE_CSS = ` @@ -18132,8 +18138,8 @@ diff --git a/client.js b/client.js + if (known && (shown === void 0 || at > shown) && !isOpen(bot)) unread.push(bot.id); + } + return { known: next, unread }; - } - ++} ++ // src/client/surface.js function groupsFor({ mode, plain = false }) { return { shell: mode === "bot", conversation: mode === "bot" && !plain }; @@ -18143,7 +18149,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +13075,8 @@ +@@ -13075,8 +13075,8 @@ function surfaceGroups(install) { }; return { reconcile(wanted) { @@ -18154,7 +18160,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +13086,7 @@ +@@ -13086,7 +13086,7 @@ function surfaceGroups(install) { } }, dispose() { @@ -18163,7 +18169,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ +@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js @@ -18172,7 +18178,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,87 +13119,165 @@ +@@ -13119,87 +13119,165 @@ function contentParts(content) { } return { text: texts2.join("\n"), attachments, rest }; } @@ -18221,7 +18227,8 @@ diff --git a/client.js b/client.js + return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel })); + }); + return bots.concat(humans); -+} + } +-function userTextParts(text, bots, referenceLabels = []) { +function messageAuthor(data, roster, sessionId) { + const source = data?.source ?? {}; + const authorId = source.authorId; @@ -18236,8 +18243,7 @@ diff --git a/client.js b/client.js + ownerName: remote?.ownerName, + mine: roster?.self?.memberId === authorId + }; - } --function userTextParts(text, bots, referenceLabels = []) { ++} +function userTextParts(text2, bots, referenceLabels = []) { const parts = []; let cursor = 0; @@ -18372,7 +18378,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +13216,115 @@ +@@ -13216,42 +13216,115 @@ var USER_MESSAGE_CSS = ` `; // src/client/approval-notices.js @@ -18454,8 +18460,8 @@ diff --git a/client.js b/client.js + off(); + timers.clearTimeout?.(timer); + }; - } - ++} ++ +// src/client/stats-dock.js +var import_react48 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -18479,9 +18485,9 @@ diff --git a/client.js b/client.js + return (0, import_react48.createElement)(entry.component, { ...props, t: t2 }); + } + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; -+} + } +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); -+ + // src/client/index.js +var import_react49 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; @@ -18501,7 +18507,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13274,13 @@ +@@ -13274,7 +13274,13 @@ function apply(ctx) { } catch { seenAt = {}; } @@ -18516,7 +18522,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13308,6 +13308,8 @@ +@@ -13308,6 +13308,8 @@ function apply(ctx) { return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -18525,7 +18531,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +13344,25 @@ +@@ -13344,13 +13344,25 @@ function apply(ctx) { } return { ...state, unread }; }); @@ -18552,7 +18558,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13358,6 +13358,11 @@ +@@ -13358,6 +13358,11 @@ function apply(ctx) { activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -18564,7 +18570,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +13368,15 @@ +@@ -13368,10 +13368,15 @@ function apply(ctx) { const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -18580,7 +18586,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13488,20 +13488,12 @@ +@@ -13488,20 +13488,12 @@ function apply(ctx) { const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -18603,7 +18609,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13520,7 @@ +@@ -13520,7 +13520,7 @@ function apply(ctx) { const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -18612,7 +18618,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +13536,7 @@ +@@ -13536,7 +13536,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -18621,7 +18627,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13588,7 @@ +@@ -13588,6 +13588,7 @@ function apply(ctx) { // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -18629,7 +18635,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13653,12 @@ +@@ -13653,12 +13653,12 @@ function apply(ctx) { }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -18645,7 +18651,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13708,15 @@ +@@ -13708,10 +13708,15 @@ function apply(ctx) { }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -18663,7 +18669,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +13719,19 @@ +@@ -13719,6 +13719,19 @@ function apply(ctx) { closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -18683,7 +18689,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +13728,7 @@ +@@ -13728,7 +13728,7 @@ function apply(ctx) { if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -18692,7 +18698,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +13757,12 @@ +@@ -13757,6 +13757,12 @@ function apply(ctx) { const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -18705,7 +18711,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13780,7 +13780,7 @@ +@@ -13780,7 +13780,7 @@ function apply(ctx) { } return { ...state, reactions }; }), @@ -18714,7 +18720,7 @@ diff --git a/client.js b/client.js clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), -@@ -13808,10 +13808,16 @@ +@@ -13808,10 +13808,16 @@ function apply(ctx) { setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), @@ -18732,7 +18738,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,31 @@ +@@ -13819,15 +13819,31 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -18765,7 +18771,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13836,7 +13836,7 @@ +@@ -13836,7 +13836,7 @@ function apply(ctx) { usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), @@ -18774,7 +18780,7 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -13846,6 +13846,8 @@ +@@ -13846,6 +13846,8 @@ function apply(ctx) { noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -18783,7 +18789,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +13890,7 @@ +@@ -13890,6 +13890,7 @@ function apply(ctx) { actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -18791,7 +18797,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13922,6 +13922,9 @@ +@@ -13922,6 +13922,9 @@ function apply(ctx) { wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -18801,7 +18807,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { -@@ -13964,7 +13964,13 @@ +@@ -13964,7 +13964,13 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -18816,7 +18822,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13989,17 @@ +@@ -13989,7 +13989,17 @@ function apply(ctx) { } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -18834,7 +18840,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +14001,12 @@ +@@ -14001,7 +14001,12 @@ function apply(ctx) { const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -18847,7 +18853,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +14010,14 @@ +@@ -14010,6 +14010,14 @@ function apply(ctx) { return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -18862,7 +18868,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,49 @@ +@@ -14019,33 +14019,49 @@ function apply(ctx) { installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -18916,7 +18922,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +14057,24 @@ +@@ -14057,15 +14057,24 @@ function apply(ctx) { // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -18944,7 +18950,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,8 +14074,17 @@ +@@ -14074,9 +14074,18 @@ function apply(ctx) { shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), @@ -18952,7 +18958,7 @@ diff --git a/client.js b/client.js + shadow("conversation.input.dock", { id: "bot-secret", order: -90, inject: face }, SecretDock), + shadow("conversation.input.dock", { id: "flybot-session", order: -80, inject: face }, FlyBotSessionDock) ]; -+ ctx.effect(() => { + ctx.effect(() => { + const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); + return () => { @@ -18960,6 +18966,7 @@ diff --git a/client.js b/client.js + style.remove(); + }; + }, "ds-bot: dead turn lines"); - ctx.effect(() => { ++ ctx.effect(() => { const style = installStyles(RETURN_CSS, "return"); return () => style.remove(); + }, "ds-bot: return styles"); diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 99687cf0..5884e93a 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -69,6 +69,8 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', + // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. + 'project-memory', 'project-memory-write', 'project-delete', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js new file mode 100644 index 00000000..1325bb3f --- /dev/null +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -0,0 +1,260 @@ +// The Project Assistant (#1712): one hidden Bot and Session per Project, memory private to that +// Project, and a 09:00 Asia/Shanghai digest built only from what the Project already knows — the +// Project State reader (2.4) and the board rows (2.3). Nothing here restates Project State, mints +// a second task identity or invents a number: every line the digest prints comes from a row or a +// file it was handed, and a day with nothing to say prints one line saying so. +// +// Identity is the install's own connector account (owner decision 2026-10-29: on a single-person +// install the Project owner is the person signed in, so `GET /user` is that person's own login). +// The shared Project App identity never stands in for a person: with no connector signed in, or on +// a host with more than one member where the Project's `ownerUserId` is not the host's own user, +// the assistant refuses and asks the owner instead. Every write it makes carries the name the +// account is posting for, because one shared GitHub login makes attribution text, not identity. +// +// The digest is not scheduled from inside the plugin: `project-create` puts exactly one 09:00 task +// on the assistant Session through the host's schedule path, and `project-delete` removes it with +// the Project. A missed beat is DSH's, and it posts nothing. +import { defineTool } from './tool-schema.js' +import { listProjectState } from './project-state.js' +import { output } from './tools.js' + +export const ASSISTANT_ROLE = 'Project Assistant' +export const DIGEST_ZONE = 'Asia/Shanghai' +export const DIGEST_TIME = '09:00' +export const DIGEST_TITLE = '09:00 Project digest' +export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' +export const MAX_ENTRY = 600 +export const MEMORY_KINDS = ['decision', 'question'] + +export const assistantName = project => `${ASSISTANT_ROLE} (${project.name})` + +export const assistantInstructions = project => [ + `You are the Project Assistant of "${project.name}". You speak for that Project and for no other.`, + 'Your memory is private to this Project: record decisions and open questions with project_memory_write, and never carry another Project\'s entries here.', + 'You may speak with the GitHub identity the host has signed in, and only while that identity is the Project owner\'s own. A write you make states the name you write as and the login you post for; the shared Project App identity is never used.', + 'Once a day, when the 09:00 task fires, call project_digest exactly once. It answers the four questions in order from the board rows and the Project State files, and prints one line on a day with no change.', +].join('\n\n') + +const refusal = (message, code = 'bot/invalid') => Object.assign(new Error(message), { code }) +const text = value => (typeof value === 'string' ? value.trim() : '') + +// The calendar day in the digest's own zone. A digest is idempotent per *day* rather than per +// 24 hours, so a retry after 09:00 still sees that today already has one. +export function dayIn(at, zone = DIGEST_ZONE) { + return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }) + .format(new Date(at)) +} + +const stamp = at => new Date(at).toISOString().slice(0, 10) + +// The four answers, in the order the issue names them, from rows the read model already carries: +// `source`, `column`, `assignee`, `updatedAt`. "Merged" is finished work a workflow run carried +// in (the run's binding is what put the row on this Project's board); "closed" is finished work a +// Bot or group chat owned. A board row has no blocked flag, so the third answer lists the cards +// still in progress and the assignee each one waits on — it never calls one blocked without that +// evidence. The next slice is the oldest card that has not started. Nothing is counted that is +// not a row, and a Project with no rows answers with no line at all. +export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now() }) { + const changed = row => since === null || (row?.updatedAt ?? 0) > since + const finished = rows.filter(row => row.column === 'done' && changed(row)) + const merged = finished.filter(row => row.source === 'workflow') + const closed = finished.filter(row => row.source !== 'workflow') + const blocked = rows.filter(row => row.column === 'in-progress' && text(row.assignee) !== '') + const next = rows + .filter(row => row.column === 'not-started') + .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id)))[0] + const line = (head, entries) => [head, ...entries.map(entry => `- ${entry}`)] + const sections = [ + ...(merged.length === 0 ? [] : line('Merged since the previous digest:', merged.map(row => `${row.id} (${row.source})`))), + ...(closed.length === 0 ? [] : line('Closed:', closed.map(row => `${row.id} (${row.source})`))), + ...(blocked.length === 0 ? [] : line('In progress, waiting on:', blocked.map(row => `${row.id} — ${text(row.assignee)}`))), + ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), + ] + const head = `${assistantName(project)} — ${stamp(at)}` + if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } + const stateLine = files.length === 0 + ? 'Project State: no Project State files read.' + : `Project State: ${files.map(file => file.path).join(', ')}` + return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } +} + +// A write is attributed by text, because one shared GitHub login makes it impossible to tell +// otherwise (#627). The login is the Project owner's own account, resolved at write time. +export const attribution = (project, login) => `Written-by: ${assistantName(project)}\nPosted-for: ${login}` + +export function install(rt) { + const projectOf = id => rt.readProject?.(id) ?? null + + const activeProject = (id) => { + const project = projectOf(text(id)) + if (project === null) throw refusal(`Unknown Project ${text(id) || '(none)'}`, 'bot/not-found') + if (project.status === 'archived') throw refusal(`Project ${project.name} is archived`, 'bot/refused') + return project + } + + const assistantOf = (project) => { + const botId = project.assistant?.botId + return typeof botId === 'string' && rt.botOf(botId) !== undefined ? project.assistant : null + } + + const memoryOf = (project) => { + const memory = rt.projectMemoryOf?.(project.id) + return { + decisions: Array.isArray(memory?.decisions) ? memory.decisions : [], + questions: Array.isArray(memory?.questions) ? memory.questions : [], + digest: memory?.digest ?? null, + } + } + + const read = (projectId) => ({ project: activeProject(projectId), memory: memoryOf(activeProject(projectId)) }) + + const write = (projectId, body = {}) => { + const project = activeProject(projectId) + const kind = text(body.kind) || 'decision' + if (!MEMORY_KINDS.includes(kind)) throw refusal(`Unknown Project memory kind ${kind}`) + const entry = text(body.text) + if (entry === '') throw refusal('A Project memory entry needs text') + if (entry.length > MAX_ENTRY) throw refusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) + rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) + return { project, memory: memoryOf(project) } + } + + // The one identity this slice accepts: the connector account, which on a single-person install + // is the owner's own. Two cases refuse and escalate instead of falling back to the app. + const identity = (projectId) => { + const project = activeProject(projectId) + const github = (rt.connectorsView?.() ?? []).find(row => row.id === 'github') + const login = text(github?.account) + if (login === '') { + return { ok: false, project, escalate: true, reason: 'no GitHub connector is signed in, so the assistant has no owner identity to post for' } + } + const members = Object.keys(rt.state?.members ?? {}) + // One member is the person signed in, so the Project owner must be that member. More than one + // member is exactly the case this slice has no answer for — there is no per-user GitHub linkage + // yet — so it refuses rather than letting one person's account post for another. + if (members.length > 1) { + return { ok: false, project, escalate: true, reason: `this host has more than one member and no per-user GitHub linkage, so Project ${project.name} cannot be posted for by any one owner` } + } + const own = members.length === 1 ? members[0] : '' + const ownerId = text(project.ownerUserId) || own + if (own !== '' && ownerId !== own) { + return { ok: false, project, escalate: true, reason: `Project ${project.name} is owned by ${ownerId}, not by this host's own user; there is no per-user GitHub linkage to post for them` } + } + return { ok: true, project, login } + } + + // One task on the assistant's Session, through the host's schedule path — never a cron of the + // plugin's own. An install with DSH's automation tasks off keeps the Project and records why the + // digest is not scheduled, because creating a Project must not depend on the schedule service. + const scheduleDigest = async (project, botId) => { + const sessionId = rt.chatOf?.(botId) ?? botId + if (typeof rt.scheduleEndpoint !== 'function') return { error: 'the schedule service is off in DSH' } + // The Bot exists but the Session index has not been rebuilt yet; a task on it must resolve as + // that Bot's own Session, or the schedule path refuses it as somebody else's. + rt.chatOwners?.set(sessionId, botId) + try { + const view = await rt.scheduleEndpoint('schedule-create', { + target: botId, + title: DIGEST_TITLE, + prompt: DIGEST_PROMPT, + when: { kind: 'daily', time: DIGEST_TIME, timeZone: DIGEST_ZONE }, + }) + const row = (view?.tasks ?? []).find(task => task.sessionId === sessionId && task.title === DIGEST_TITLE) + return { scheduleId: row?.id ?? null } + } catch (error) { + return { error: error?.message ?? String(error) } + } + } + + const create = async (projectId) => { + const project = activeProject(projectId) + const existing = assistantOf(project) + if (existing !== null) return { project, assistant: existing, created: false } + const bot = await rt.createBot({ + name: assistantName(project), + role: ASSISTANT_ROLE, + instructions: assistantInstructions(project), + hidden: true, + ownerId: text(project.ownerUserId) || rt.initialOwnerId?.(), + }) + // The assistant and its binding are plumbing, not a change of the Project: a Project + // created with one still answers `createdAt === updatedAt`. + const stamped = project.updatedAt + rt.bindProject?.({ kind: 'bot', id: bot.id, projectId: project.id }) + project.updatedAt = stamped + const schedule = await scheduleDigest(project, bot.id) + const assistant = { botId: bot.id, sessionId: rt.chatOf?.(bot.id) ?? bot.id, createdAt: Date.now(), ...schedule } + project.assistant = assistant + await rt.save?.() + return { project, assistant, created: true } + } + + const remove = async (projectId) => { + const project = projectOf(text(projectId)) + if (project === null) return null + const assistant = assistantOf(project) + if (assistant === null) return null + if (typeof rt.scheduleEndpoint === 'function' && typeof assistant.scheduleId === 'string') { + await rt.scheduleEndpoint('schedule-delete', { sessionId: assistant.sessionId, id: assistant.scheduleId }) + .catch(error => rt.warn?.('ds-bot: removing the Project Assistant schedule failed: %s', error?.message ?? error)) + } + await rt.deleteBot?.(assistant.botId) + .catch(error => rt.warn?.('ds-bot: removing the Project Assistant Bot failed: %s', error?.message ?? error)) + delete project.assistant + await rt.save?.() + return assistant + } + + // One stored Project note and one delivered message, at most once per calendar day. The second + // build in a day answers `skipped` and writes nothing; it still resolves identity first, because + // a refused identity must not leave a note behind. + const digest = async (projectId, { at = Date.now(), force = false } = {}) => { + const who = identity(projectId) + if (!who.ok) return { ok: false, escalated: who.escalate === true, reason: who.reason } + const { project, login } = who + const memory = memoryOf(project) + const day = dayIn(at) + if (!force && memory.digest?.day === day) { + return { ok: true, skipped: true, day, reason: 'today already has a digest', project } + } + const rows = rt.projectBoard?.(project.id) ?? [] + const listed = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) + const files = listed?.ok === true ? listed.files : [] + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at }) + const body = [attribution(project, login), built.text].join('\n\n') + const note = { day: built.day, at, text: built.text } + rt.setProjectDigest?.(project.id, note) + const toId = text(project.ownerUserId) === '' ? rt.state?.mainBotIds?.[0] : (rt.state?.members?.[project.ownerUserId]?.designatedChiefId ?? rt.state?.mainBotIds?.[0]) + if (typeof toId === 'string' && toId !== '') { + await rt.deliver?.({ + fromId: project.assistant?.botId ?? toId, + toId, + sessionId: rt.chatOf?.(toId) ?? toId, + text: body, + role: ASSISTANT_ROLE, + }) + } + return { ok: true, skipped: false, day: built.day, changed: built.changed, text: body, project } + } + + rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest } + + // The assistant's own Session calls this. The Project comes from the Session's bound Bot, never + // from the arguments, so one Project's assistant cannot reach another Project's memory. + rt.ctx.tools.register(defineTool({ + name: 'project_digest', + description: 'Build the Project digest for the Project your Session is bound to: what merged since the previous digest, what closed, which cards are still in progress and on whom, and the next slice. It stores one Project note and delivers one message to the owner, at most once per day; the second call in a day adds nothing and says so.', + parameters: {}, + output, + async execute(_args, exec) { + await rt.load?.() + const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const bot = botId === undefined ? undefined : rt.botOf(botId) + const projectId = text(bot?.projectId) + if (projectId === '') return 'This Session is not a Project Assistant, so it has no Project digest to build.' + const result = await digest(projectId) + if (result.ok !== true) return `The digest was not posted: ${result.reason}` + return result.skipped === true ? `Today already has a digest (${result.day}); this call added nothing.` : result.text + }, + })) +} diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js new file mode 100644 index 00000000..44ec1b7e --- /dev/null +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -0,0 +1,234 @@ +// The Project Assistant (#1712): one hidden assistant per Project, memory that never crosses a +// Project id, a digest built only from the Project State reader and the board rows, and exactly one +// 09:00 Asia/Shanghai task on the assistant's own Session that goes away with the Project. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' +import { buildDigest, dayIn } from '../src/host/project-assistant.js' + +const seed = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' } }, + rooms: {}, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, + grants: {}, +}) + +// DSH's schedule service, in memory: enough for the host's own create/delete path and its view. +function scheduleService() { + const tasks = new Map() + const created = [] + let next = 1 + return { + tasks, + created, + async create(sessionId, request) { + created.push({ sessionId, request }) + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], kind, scheduledAt: '2030-01-01T01:00:00.000Z' } + tasks.set(id, { sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async delete({ sessionId, id }) { + const task = tasks.get(id) + if (task === undefined || task.sessionId !== sessionId) return { deleted: false } + tasks.delete(id) + return { deleted: true } + }, + } +} + +async function start({ github = 'Birfy' } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-assistant-')) + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const service = scheduleService() + const ctx = fakeContext({ services: { schedule: service } }) + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) + return agents.get(sessionId) + } + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + rt.connectorsView = () => (github === null ? [] : [{ id: 'github', name: 'GitHub', state: 'ready', account: github }]) + const delivered = [] + rt.deliver = async message => { delivered.push(message) } + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { rt, service, delivered, stop } +} + +const created = async (rt, body) => (await rt.projectEndpoint('project-create', body)).project + +test('memory is private to one Project and an unknown id is refused', async () => { + const { rt, stop } = await start() + try { + const one = await created(rt, { name: 'One', ownerUserId: 'owner-1' }) + const two = await created(rt, { name: 'Two', ownerUserId: 'owner-1' }) + await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'decision', text: 'Ship the memory store' }) + await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'question', text: 'Who reviews it?' }) + const mine = (await rt.projectEndpoint('project-memory', { id: one.id })).memory + assert.deepEqual(mine.decisions.map(entry => entry.text), ['Ship the memory store']) + assert.deepEqual(mine.questions.map(entry => entry.text), ['Who reviews it?']) + const theirs = (await rt.projectEndpoint('project-memory', { id: two.id })).memory + assert.deepEqual(theirs.decisions, []) + assert.deepEqual(theirs.questions, []) + await assert.rejects(() => rt.projectEndpoint('project-memory', { id: 'project-missing' }), /Unknown Project/) + assert.throws(() => rt.projectAssistant.read('project-missing'), error => error.code === 'bot/not-found') + } finally { + await stop() + } +}) + +test('the assistant refuses an unknown Project and an archived one', async () => { + const { rt, stop } = await start() + try { + await assert.rejects(() => rt.projectAssistant.create('project-missing'), error => error.code === 'bot/not-found') + const project = await created(rt, { name: 'Archived', ownerUserId: 'owner-1' }) + rt.readProject(project.id).status = 'archived' + await assert.rejects(() => rt.projectAssistant.create(project.id), /is archived/) + } finally { + await stop() + } +}) + +test('the digest names only the facts the rows carry, in the four-section order', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Digest', ownerUserId: 'owner-1' }) + const at = Date.parse('2026-10-29T01:00:00Z') + rt.projectBoard = () => [ + { id: 't-merged', source: 'workflow', column: 'done', assignee: null, updatedAt: at - 60_000 }, + { id: 't-closed', source: 'bot', column: 'done', assignee: null, updatedAt: at - 30_000 }, + { id: 't-waiting', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: at - 10_000 }, + { id: 't-next', source: 'group', column: 'not-started', assignee: null, updatedAt: at - 5_000 }, + ] + const result = await rt.projectAssistant.digest(project.id, { at }) + assert.equal(result.ok, true) + assert.equal(result.skipped, false) + const text = result.text + assert.match(text, /^Written-by: Project Assistant \(Digest\)\nPosted-for: Birfy\n/) + for (const id of ['t-merged', 't-closed', 't-waiting', 't-next']) assert.match(text, new RegExp(id)) + assert.match(text, /waiting on:\n- t-waiting — Writer|In progress, waiting on:\n- t-waiting — Writer/) + assert.doesNotMatch(text, /t-absent/, 'a row that does not exist is never named') + const merged = text.indexOf('Merged since the previous digest:') + const closed = text.indexOf('Closed:') + const blocked = text.indexOf('In progress, waiting on:') + const next = text.indexOf('Next slice:') + assert.ok(merged > 0 && merged < closed && closed < blocked && blocked < next, text) + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test('a day with no change is one line and the second build adds nothing', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Quiet', ownerUserId: 'owner-1' }) + const at = Date.parse('2026-10-29T01:00:00Z') + rt.projectBoard = () => [] + const first = await rt.projectAssistant.digest(project.id, { at }) + assert.equal(first.changed, false) + assert.match(first.text, /no change since the previous digest\.$/) + const second = await rt.projectAssistant.digest(project.id, { at: at + 60_000 }) + assert.equal(second.skipped, true) + assert.equal(second.reason, 'today already has a digest') + assert.equal(delivered.length, 1, 'one delivery for one day') + assert.equal(dayIn(at), dayIn(at + 60_000)) + const note = rt.projectMemoryOf(project.id).digest + assert.equal(note.day, dayIn(at)) + assert.match(note.text, /no change/) + } finally { + await stop() + } +}) + +test('identity refuses with no connector and on a multi-member host that is not the owner', async () => { + const { rt, delivered, stop } = await start({ github: null }) + try { + const project = await created(rt, { name: 'No Identity', ownerUserId: 'owner-1' }) + const refused = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(refused.ok, false) + assert.equal(refused.escalated, true) + assert.match(refused.reason, /no GitHub connector is signed in/) + assert.equal(delivered.length, 0, 'a refused identity posts nothing') + assert.equal(rt.projectMemoryOf(project.id)?.digest ?? null, null, 'and leaves no note behind') + + rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } + const other = await created(rt, { name: 'Somebody Else', ownerUserId: 'owner-2' }) + const refusedToo = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(refusedToo.ok, false) + assert.equal(refusedToo.escalated, true) + assert.match(refusedToo.reason, /no per-user GitHub linkage/) + assert.equal(delivered.length, 0) + } finally { + await stop() + } +}) + +test('project-create makes exactly one 09:00 Asia/Shanghai task and project-delete takes it away', async () => { + const { rt, service, stop } = await start() + try { + const project = await created(rt, { name: 'Scheduled', ownerUserId: 'owner-1' }) + assert.equal(service.created.length, 1) + const request = service.created[0].request + assert.equal(request.title, '09:00 Project digest') + assert.deepEqual(request.daily, { time: '09:00:00', time_zone: 'Asia/Shanghai' }) + assert.equal(project.assistant.scheduleId, 'schedule-1') + const assistant = rt.botOf(project.assistant.botId) + assert.equal(assistant.hidden, true) + assert.equal(assistant.projectId, project.id) + assert.equal(service.tasks.size, 1) + + await rt.projectEndpoint('project-delete', { id: project.id }) + assert.equal(rt.readProject(project.id), null) + assert.equal(service.tasks.size, 0) + assert.equal(rt.projectMemoryOf(project.id), null) + } finally { + await stop() + } +}) + +test('buildDigest is pure: the same rows answer the same four sections', () => { + const project = { id: 'project-1', name: 'Pure' } + const rows = [ + { id: 'b', source: 'workflow', column: 'done', assignee: null, updatedAt: 20 }, + { id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }, + ] + const one = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) + const two = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(one.text, two.text) + assert.match(one.text, /Project Assistant \(Pure\) — 2026-10-29/) + const after = buildDigest({ project, rows, since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.doesNotMatch(after.text, /b \\(workflow\\)/, 'a finished card older than the previous digest is not reported again') + assert.match(after.text, /waiting on/) + const quiet = buildDigest({ project, rows: [], since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(quiet.changed, false) + assert.match(quiet.text, /no change since the previous digest\.$/) +}) diff --git a/patches/ds-bot/files/test/run-bot-tools.test.js b/patches/ds-bot/files/test/run-bot-tools.test.js index cfb68658..ae7ce56e 100644 --- a/patches/ds-bot/files/test/run-bot-tools.test.js +++ b/patches/ds-bot/files/test/run-bot-tools.test.js @@ -111,8 +111,8 @@ test('the constant refuses every name at the guard and the approval seam, with n const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() t.after(stop) const id = await runBot(fb, []) - assert.equal(EPHEMERAL_DENIED_TOOLS.length, 30) - assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 30, 'no duplicate name') + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 31) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 31, 'no duplicate name') assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only')