chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
/** View contracts recovered from REA ev_717fa5e… / src-Cng7axVa.js.
* Optional host services are deliberately separate from the shipped client dispatcher.
*/
export interface ArtifactFileChange {
uri?: string
diff?: string
changeType?: string
language?: string
diffStatus?: string
cachedContent?: string
}
export interface PreviewArtifact {
id: string
type: string
title?: string
category?: string
customType?: string
uri?: string
url?: string
content?: string
contentType?: string
mimeType?: string
language?: string
originalContent?: string
modifiedContent?: string
enableEdit?: boolean
size?: number
fileChangeInfo?: ArtifactFileChange
}
+6
View File
@@ -0,0 +1,6 @@
import type { CustomModelProfile } from './models'
/** Freeze the selected profile's identity before a turn can outlive its settings. */
export function freezeAssistantModelMetadata(model: Pick<CustomModelProfile, 'id' | 'name'>): { modelId: string; modelName: string } {
return { modelId: model.id, modelName: model.name }
}
+50
View File
@@ -0,0 +1,50 @@
export interface BrowserHostBounds { x: number; y: number; width: number; height: number }
export interface BrowserHostView {
id: string
sessionId?: string
url: string
title: string
loading: boolean
canGoBack: boolean
canGoForward: boolean
visible: boolean
error?: string
}
export interface BrowserHostApi {
create(sessionId?: string, initialUrl?: string): Promise<BrowserHostView>
get(id: string, sessionId?: string): Promise<BrowserHostView>
navigate(id: string, url: string, sessionId?: string): Promise<BrowserHostView>
back(id: string, sessionId?: string): Promise<BrowserHostView>
forward(id: string, sessionId?: string): Promise<BrowserHostView>
reload(id: string, sessionId?: string): Promise<BrowserHostView>
bounds(id: string, bounds: BrowserHostBounds | null, sessionId?: string): Promise<void>
close(id: string, sessionId?: string): Promise<void>
rebind(id: string, previousSessionId: string | undefined, nextSessionId: string): Promise<BrowserHostView>
}
export function normalizeBrowserHostUrl(value: string): string {
if (typeof value !== 'string') throw new Error('网页地址无效')
const text = value.trim()
if (text === 'about:blank') return text
const url = new URL(text)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('仅支持 HTTP 或 HTTPS 网页地址')
return url.toString()
}
/** Source address normalization prefers HTTP for local loopback and HTTPS for bare hosts. */
export function normalizeBrowserAddressInput(value: string): string {
const text = value.trim()
if (!text) throw new Error('请输入网址')
// REA media-preview-DMz4TW4q.js looksLikeUrl / buildSearchUrl defaults to Sogou.
const scheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(text)
const looksLikeUrl = !/\s/.test(text) && (scheme || /^(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(text) || /^[^\s]+\.[^\s]{2,}/.test(text))
if (!looksLikeUrl && !scheme) return `https://www.sogou.com/web?query=${encodeURIComponent(text)}`
return normalizeBrowserHostUrl(/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(text) ? text
: `${/^(localhost|127\.0\.0\.1)(:\d+)?/i.test(text) ? 'http' : 'https'}://${text}`)
}
export function clampBrowserHostBounds(value: BrowserHostBounds, width: number, height: number): BrowserHostBounds {
for (const field of ['x', 'y', 'width', 'height'] as const) if (!Number.isInteger(value[field]) || !Number.isFinite(value[field])) throw new Error('浏览器区域必须使用有限整数坐标')
if (value.width < 0 || value.height < 0) throw new Error('浏览器区域尺寸无效')
const x = Math.min(Math.max(0, value.x), width), y = Math.min(Math.max(0, value.y), height)
return { x, y, width: Math.max(0, Math.min(value.x + value.width, width) - x),
height: Math.max(0, Math.min(value.y + value.height, height) - y) }
}
+99
View File
@@ -0,0 +1,99 @@
/** Local capability contracts. Cloud catalogs and authorization are separate services. */
export type LocalizedExpertText = string | { zh?: string; en?: string }
export interface ExpertRecord {
id: string
name: string
displayName?: LocalizedExpertText
profession?: LocalizedExpertText
description: string
displayDescription?: LocalizedExpertText
version: string
expertType: 'agent' | 'team' | 'skill' | 'plugin'
agentName?: string
plugin: string
pluginRegisteredName: string
marketplace: 'my-experts'
isCustomExpert: true
expertRootDir: string
avatar?: string
defaultInitPrompt?: LocalizedExpertText
tags?: LocalizedExpertText[]
quickPrompts?: LocalizedExpertText[]
skills?: string[]
categoryId?: string
members?: Array<{ id: string; displayName?: LocalizedExpertText; profession?: LocalizedExpertText; avatar?: string; role?: 'lead' | 'member' }>
createdBySession?: string
updatedAt: number
error?: string
}
export interface ExpertDocument { expert: ExpertRecord; files: Record<string, string>; prompt: string }
export interface ExpertBinding { id: string; runtimeIdentity: string; name: string; profession?: string; avatarUrl?: string; expertType: ExpertRecord['expertType'] }
/** Activation metadata comes from the same validated package as the binding. */
export interface ExpertActivation extends ExpertBinding { defaultInitPrompt?: LocalizedExpertText }
export interface ExpertsApi {
list(): Promise<ExpertRecord[]>
read(id: string): Promise<ExpertDocument>
activate(id: string): Promise<ExpertActivation>
remove(id: string): Promise<void>
openPath(id: string): Promise<void>
}
export function expertText(value: LocalizedExpertText | undefined): string { return typeof value === 'string' ? value : value?.zh || value?.en || '' }
export interface SkillRecord {
id: string
name: string
description: string
version?: string
path: string
scope: 'user' | 'workspace'
enabled: boolean
userInvocable: boolean
disableModelInvocation: boolean
source: 'imported' | 'discovered'
userModified: boolean
error?: string
}
export interface SkillDocument { skill: SkillRecord; content: string; files: string[] }
export interface SkillsApi {
list(cwd?: string): Promise<SkillRecord[]>
importPath(path?: string, overwrite?: boolean, kind?: 'directory' | 'zip'): Promise<SkillRecord | null>
read(id: string, cwd?: string): Promise<SkillDocument>
setEnabled(id: string, enabled: boolean, cwd?: string): Promise<void>
remove(id: string): Promise<void>
export(id: string, destination?: string): Promise<string | null>
}
export type McpStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
export interface McpServerInput {
id?: string
name: string
transport: 'stdio' | 'http'
command?: string
args?: string[]
cwd?: string
url?: string
/** Empty existing values preserve stored secrets. */
env?: Record<string, string>
headers?: Record<string, string>
enabled?: boolean
}
export interface McpToolView { name: string; description: string; inputSchema: Record<string, unknown>; enabled: boolean }
export interface McpServerView extends Omit<McpServerInput, 'id' | 'env' | 'headers'> {
id: string
enabled: boolean
status: McpStatus
error?: string
tools: McpToolView[]
envKeys: string[]
headerKeys: string[]
configHash: string
trusted: boolean
}
export interface McpApi {
list(): Promise<McpServerView[]>
save(input: McpServerInput): Promise<McpServerView>
trust(id: string, configHash: string): Promise<void>
connect(id: string): Promise<McpServerView>
disconnect(id: string): Promise<void>
setEnabled(id: string, enabled: boolean): Promise<void>
setToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void>
remove(id: string): Promise<void>
}
+62
View File
@@ -0,0 +1,62 @@
/** YCode transport types for the restored WorkBuddy view contracts. */
import type { PreviewArtifact } from './artifacts'
export type PermissionMode = 'default-sandbox' | 'full-access'
/** Conversation behavior, independent of filesystem approval/permission mode. */
export type AgentMode = 'craft' | 'plan' | 'ask'
export interface ChatAttachment {
id: string
name: string
mimeType: string
size: number
previewUrl?: string
path?: string
}
export interface QuestionOption {
id?: string
option?: string
label?: string
question?: string
description?: string
hint?: string
}
export interface QuestionItem {
id: string
question: string
options: Array<string | QuestionOption>
multiSelect?: boolean
}
export interface QuestionAnswer {
title?: string
questions: Array<{ id: string; question: string; answers: string[] }>
}
export interface PermissionOption { optionId: string; name: string; kind: string }
export type InteractionStatus = 'pending' | 'responding' | 'answered' | 'rejected' | 'cancelled' | 'error'
interface InteractionBase {
id: string
runId: string
toolCallId: string
title: string
status: InteractionStatus
error?: string
}
export type ChatInteraction =
| (InteractionBase & { kind: 'question'; questions: QuestionItem[]; answer?: QuestionAnswer; skipped?: boolean })
| (InteractionBase & { kind: 'approval'; requestId: string; options: PermissionOption[]; selectedOptionId?: string; scope?: string })
export type InteractionResponse =
| { kind: 'question'; answer: QuestionAnswer; skipped?: boolean }
| { kind: 'approval'; optionId: string }
export interface PlanStep { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' | 'cancelled' }
export interface PlanContent { id: string; title: string; steps: PlanStep[]; filePath?: string }
export interface TaskContent {
id: string
title: string
description?: string
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'
summary?: string
toolCount?: number
durationMs?: number
}
export interface ArtifactContent { id: string; name: string; path: string; mimeType?: string; description?: string; preview?: PreviewArtifact }
+149
View File
@@ -0,0 +1,149 @@
/**
* IPC 契约(主进程与 preload 共享)。
* 放在 shared 下,避免主进程反向依赖 electron 类型。
*/
import type {
ChatMessage,
ChatEvent,
SessionMeta,
GatewayConfigView,
WorkspaceInfo,
WindowState,
AppInfo
} from './types'
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
import type { ChatAttachment, InteractionResponse, PermissionMode } from './conversation'
import type { CustomModelInput, ModelRegistryView, ModelTestResult } from './models'
import type { SessionContext, WorkspaceRegistryView } from './workspaces'
import type { BrowserHostApi } from './browser-host'
import type { TerminalHostApi } from './terminal-host'
import type { ExpertsApi, McpApi, SkillsApi } from './capabilities'
export interface ConfigPatchLike {
speechBaseUrl?: string
speechModel?: string
baseUrl?: string
model?: string
temperature?: number
maxToolRounds?: number
apiKey?: string
}
export type EditAction = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'
export interface SendPayload {
skillIds?: string[]
planId?: string
resources?: string[]
sessionId: string
text: string
attachments?: ChatAttachment[]
permissionMode?: PermissionMode
/** Retry/edit from this message; descendants are preserved in an archived branch. */
fromMessageId?: string
action?: 'edit' | 'resend' | 'regenerate'
}
export interface SendResult {
runId: string
assistantMessageId: string
/** Frozen identity of the model selected when this assistant turn started. */
modelId?: string
modelName?: string
assistantDisplay?: import('./capabilities').ExpertBinding
userMessage: ChatMessage
messages?: ChatMessage[]
}
/** 统一的返回包装,便于渲染进程处理错误而不炸掉 UI */
export type Ipc<T> = Promise<T>
/** A cancelled folder picker has no attached ID, even when the registry has a selection. */
export interface WorkspaceAttachResult extends WorkspaceRegistryView {
attachedWorkspaceId?: string
}
/** preload 暴露给渲染进程的 API 形状 */
export interface YCodeApi {
experts: ExpertsApi
skills: SkillsApi
mcp: McpApi
browser: BrowserHostApi
terminal: TerminalHostApi
window: {
minimize(): Ipc<void>
toggleMaximize(): Ipc<{ maximized: boolean }>
close(): Ipc<void>
getState(): Ipc<WindowState>
onState(listener: (state: WindowState) => void): () => void
onFocus(listener: (focused: boolean) => void): () => void
}
edit: {
action(action: EditAction): Ipc<void>
}
app: {
info(): Ipc<AppInfo>
openConfigDir(): Ipc<void>
openSettings(section?: string, action?: 'new'): Ipc<void>
onSettingsChanged(listener: () => void): () => void
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
}
config: {
get(): Ipc<GatewayConfigView>
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
}
workspace: {
get(): Ipc<WorkspaceInfo>
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
list(): Ipc<WorkspaceRegistryView>
generate(name: string): Ipc<WorkspaceRegistryView>
attach(path?: string): Ipc<WorkspaceAttachResult>
select(id: string | null): Ipc<WorkspaceRegistryView>
rename(id: string, name: string): Ipc<WorkspaceRegistryView>
remove(id: string): Ipc<WorkspaceRegistryView>
open(id: string): Ipc<void>
}
models: {
list(): Ipc<ModelRegistryView>
save(input: CustomModelInput, overwrite?: boolean): Ipc<ModelRegistryView>
remove(id: string): Ipc<ModelRegistryView>
select(id: string | null): Ipc<ModelRegistryView>
test(input: CustomModelInput): Ipc<ModelTestResult>
}
resources: {
list(sessionId?: string, workspaceId?: string): Ipc<ResourceEntry[]>
openExternal(url: string): Ipc<void>
read(path: string, range?: ResourceRange, sessionId?: string): Ipc<WorkspaceResource>
readText(path: string, sessionId?: string): Ipc<string>
classify(path: string, sessionId?: string): Ipc<'file' | 'directory' | 'unknown'>
resolveImage(source: string, sessionId?: string): Ipc<string | undefined>
download(payload: DownloadPayload): Ipc<DownloadResult>
downloadImage(source: string, filename?: string, sessionId?: string): Ipc<DownloadResult>
}
attachments: {
pick(): Ipc<ChatAttachment[]>
stage(payload: { name: string; mimeType: string; dataBase64: string }): Ipc<ChatAttachment>
remove(id: string): Ipc<void>
}
speech: {
transcribe(payload: { audioBase64: string; mimeType: string }): Ipc<{ text: string }>
}
session: {
list(): Ipc<SessionMeta[]>
create(title?: string, context?: SessionContext): Ipc<SessionMeta>
updateContext(id: string, context: SessionContext): Ipc<SessionMeta>
messages(sessionId: string): Ipc<ChatMessage[]>
rename(sessionId: string, title: string): Ipc<void>
remove(sessionId: string): Ipc<void>
}
chat: {
revert(sessionId: string, userMessageId: string): Ipc<{ messages: ChatMessage[] }>
feedback(sessionId: string, messageId: string, value: 'like' | 'dislike'): Ipc<void>
send(payload: SendPayload): Ipc<SendResult>
cancel(runId: string): Ipc<{ cancelled: boolean }>
respond(runId: string, interactionId: string, response: InteractionResponse): Ipc<{ accepted: boolean }>
/** 返回取消订阅函数 */
onEvent(listener: (event: ChatEvent) => void): () => void
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { ChatEvent, ChatMessage, MessagePart, ToolCallRecord } from './types'
/** 老会话只有合并正文与工具数组;不臆造其已经丢失的发生顺序。 */
export function getMessageParts(message: ChatMessage): MessagePart[] {
if (message.parts?.length) return message.parts
return [
...(message.content ? [{ type: 'text' as const, text: message.content }] : []),
...(message.tools ?? []).map((tool) => ({ type: 'tool' as const, toolId: tool.id }))
]
}
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
switch (event.type) {
case 'part': {
const parts = [...getMessageParts(message)]
const identity = (part: MessagePart): string | undefined => {
if (part.type === 'reasoning' || part.type === 'image') return `${part.type}:${part.id}`
if (part.type === 'plan') return `plan:${part.plan.id}`
if (part.type === 'task') return `task:${part.task.id}`
if (part.type === 'artifact') return `artifact:${part.artifact.id}`
return undefined
}
const key = identity(event.part)
const index = parts.findIndex((part) => identity(part) === key)
if (index < 0) parts.push(event.part)
else parts[index] = event.part
return { ...message, parts }
}
case 'interaction': {
const interactions = [...(message.interactions ?? [])]
const index = interactions.findIndex((item) => item.id === event.interaction.id)
if (index < 0) interactions.push(event.interaction)
else interactions[index] = event.interaction
return { ...message, interactions }
}
case 'text': {
if (!event.delta) return message
const parts = getMessageParts(message).map((part) => ({ ...part }))
const last = parts.at(-1)
if (last?.type === 'text') last.text += event.delta
else parts.push({ type: 'text', text: event.delta })
return { ...message, content: message.content + event.delta, parts }
}
case 'tool_call':
case 'tool_result': {
const tools = [...(message.tools ?? [])]
const index = tools.findIndex((tool) => tool.id === event.call.id)
if (index < 0) tools.push({ ...event.call })
else tools[index] = { ...tools[index], ...event.call }
const parts = [...getMessageParts(message)]
if (!parts.some((part) => part.type === 'tool' && part.toolId === event.call.id)) {
parts.push({ type: 'tool', toolId: event.call.id })
}
return { ...message, tools, parts }
}
case 'done': {
let parts = getMessageParts(message)
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
// 没有增量的回合仍能消费完整终态;正常流式回合保留已知顺序。
if (text !== event.content) {
// Correct the plain text while retaining independently ordered rich content.
const firstTextIndex = parts.findIndex((part) => part.type === 'text')
parts = parts.filter((part) => part.type !== 'text')
if (event.content) parts.splice(firstTextIndex < 0 ? 0 : Math.min(firstTextIndex, parts.length), 0, { type: 'text', text: event.content })
} else {
parts = [...parts]
}
for (const tool of event.tools) {
if (!parts.some((part) => part.type === 'tool' && part.toolId === tool.id)) {
parts.push({ type: 'tool', toolId: tool.id })
}
}
return { ...message, content: event.content, tools: event.tools, parts, status: 'done', ...terminalDuration(event) }
}
case 'cancelled':
case 'error': {
const status = event.type === 'cancelled' ? 'cancelled' : 'error'
const tools = message.tools?.map((tool): ToolCallRecord =>
tool.status === 'running' ? { ...tool, status } : tool
)
const interactions = message.interactions?.map((item) => item.status === 'pending' || item.status === 'responding' ? { ...item, status: 'cancelled' as const } : item)
return { ...message, tools, interactions, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
}
default:
return message
}
}
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt'> {
return {
...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0
? { turnDurationMs: event.turnDurationMs } : {}),
turnStartedAt: undefined
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { GatewayConfig } from './types'
export interface CustomModelProfile {
id: string
name: string
providerId: string
model: string
baseUrl: string
hasApiKey: boolean
keyStorage: 'encrypted' | 'plaintext' | 'none'
authMode?: 'api-key' | 'none'
supportsToolCall?: boolean
supportsImages?: boolean
supportsReasoning?: boolean
onlyReasoning?: boolean
canDisableThinking?: boolean
defaultEffort?: string
supportedEfforts?: string[]
useCustomProtocol?: boolean
maxInputTokens?: number
maxOutputTokens?: number
temperature?: number
maxToolRounds?: number
}
export interface CustomModelInput extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
/** Empty or omitted during editing preserves the existing secret. */
apiKey?: string
clearApiKey?: boolean
previousId?: string
}
export interface ModelRegistryView {
models: CustomModelProfile[]
selectedModelId: string | null
}
export interface ModelTestResult {
success: boolean
message: string
durationMs: number
}
/** Main-process-only resolved credentials; never expose this through preload. */
export interface ResolvedModel {
profile: CustomModelProfile
config: GatewayConfig
}
+22
View File
@@ -0,0 +1,22 @@
export interface OpenAIEndpoint {
api: 'openai-completions' | 'openai-responses'
/** SDK root; the provider appends its protocol path. Stored user settings stay intact. */
baseUrl: string
endpoint: string
}
/** A complete Responses URL selects that protocol; ordinary API roots retain Chat compatibility. */
export function resolveOpenAIEndpoint(raw: string): OpenAIEndpoint {
const url = new URL(raw.trim())
const path = url.pathname.replace(/\/+$/, '')
const responses = /\/responses$/i.test(path)
const complete = responses || /\/chat\/completions$/i.test(path)
const protocolPath = responses ? '/responses' : '/chat/completions'
const root = complete ? path.slice(0, -protocolPath.length) : path
const base = new URL(url)
base.pathname = root || '/'
const target = new URL(url)
target.pathname = root + protocolPath
return { api: responses ? 'openai-responses' : 'openai-completions',
baseUrl: base.href.replace(/\/$/, ''), endpoint: target.href }
}
+17
View File
@@ -0,0 +1,17 @@
import type { PreviewArtifact } from './artifacts'
export interface ResourceRange { start: number; end?: number }
export interface ResourceEntry { name: string; path: string; type: 'file' | 'directory' }
export type WorkspaceResource =
| { kind: 'artifact'; path: string; name: string; content: string; artifactType?: string; artifact?: PreviewArtifact }
| { kind: 'text'; path: string; name: string; content: string; firstLine: number; totalLines: number; range?: ResourceRange; truncated: boolean }
| { kind: 'image'; path: string; name: string; url: string }
| { kind: 'directory'; path: string; name: string; entries: ResourceEntry[]; truncated: boolean }
export interface DownloadPayload {
filename: string
mimeType: string
data: string
encoding: 'utf8' | 'base64'
}
export interface DownloadResult { saved: boolean; path?: string }
+26
View File
@@ -0,0 +1,26 @@
import type { ChatMessage } from './types'
export interface RevertChange { path: string; before: string | null; after: string }
const readOnly = new Set(['read_file', 'list_dir', 'ask_user_question', 'ask_followup_question', 'todo_write', 'create_plan'])
const writes = new Set(['write_file', 'edit_file'])
/** Only host writes with complete before/after records can offer a real rollback. */
export function getRevertChanges(messages: ChatMessage[], userMessageId: string): RevertChange[] {
const index = messages.findIndex((message) => message.id === userMessageId && message.role === 'user')
if (index < 0) throw new Error('未找到用户消息')
const changes = new Map<string, RevertChange>()
for (const message of messages.slice(index)) {
if (message.status === 'streaming') throw new Error('请先停止当前运行')
for (const tool of message.tools ?? []) {
if (readOnly.has(tool.name)) continue
if (!writes.has(tool.name)) throw new Error('此版本包含无法完整回滚的工具操作')
const data = tool.result as Partial<{ path: string; oldContent: string | null; newContent: string }> | undefined
if (!data || typeof data.path !== 'string' || !(data.oldContent === null || typeof data.oldContent === 'string') || typeof data.newContent !== 'string') {
if (tool.status === 'error' || tool.status === 'cancelled') continue
throw new Error('此版本缺少文件修改记录')
}
const existing = changes.get(data.path)
if (existing && existing.after !== data.oldContent) throw new Error('文件在两次工具修改之间发生过外部编辑')
changes.set(data.path, { path: data.path, before: existing ? existing.before : data.oldContent, after: data.newContent })
}
}
return [...changes.values()]
}
+33
View File
@@ -0,0 +1,33 @@
/** Local PTY handles are scoped to their creating window and conversation. */
export interface TerminalReplayChunk { data: string; cols: number; rows: number }
export interface TerminalHostView {
id: string
sessionId?: string
cwd: string
title: string
shell: string
cols: number
rows: number
/** Bounded ANSI output replay; credentials are never added to the shell environment. */
buffer: string
replay: TerminalReplayChunk[]
sequence: number
exited: boolean
exitCode?: number
}
export type TerminalHostEvent = {
id: string
sessionId?: string
sequence: number
} & ({ type: 'data'; data: string } | { type: 'exit'; exitCode: number })
export interface TerminalHostApi {
create(sessionId?: string, cols?: number, rows?: number): Promise<TerminalHostView>
get(id: string, sessionId?: string): Promise<TerminalHostView>
write(id: string, data: string, sessionId?: string): Promise<void>
resize(id: string, cols: number, rows: number, sessionId?: string): Promise<void>
clear(id: string, sessionId?: string): Promise<void>
rebind(id: string, previousSessionId: string | undefined, nextSessionId: string): Promise<TerminalHostView>
close(id: string, sessionId?: string): Promise<void>
onEvent(listener: (event: TerminalHostEvent) => void): () => void
}
+169
View File
@@ -0,0 +1,169 @@
/**
* 主进程 / 渲染进程共享的类型与 IPC 契约。
*
* 已存 API Key 不返回渲染进程;用户可在模型表单编辑地址和新密钥。
* 模型网络请求经由主进程网关或 OMP 子进程完成。
*/
/** 会话内的一条消息(对外视图) */
import type { ArtifactContent, ChatAttachment, ChatInteraction, PlanContent, TaskContent } from './conversation'
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
/** 助手消息携带的工具调用记录 */
tools?: ToolCallRecord[]
/** 按事件顺序记录正文与工具位置;旧会话没有此字段时按兼容方式渲染。 */
parts?: MessagePart[]
attachments?: ChatAttachment[]
resources?: string[]
skills?: Array<{ id: string; name: string; uri: string }>
/** Native-history reconstruction; never includes connector credentials. */
capabilityContext?: string
expertId?: string
assistantDisplay?: import('./capabilities').ExpertBinding
/** The model selected when this assistant turn started; kept with history. */
modelId?: string
modelName?: string
interactions?: ChatInteraction[]
requestId?: string
planId?: string
nativeEntryId?: string
nativeSessionPath?: string
feedback?: 'like' | 'dislike'
interrupted?: boolean
createdAt: number
/** User-submit timestamp shared by the live timer and terminal turn duration. */
turnStartedAt?: number
/** Measured user-send to terminal-event duration, used by the source completed fold. */
turnDurationMs?: number
/** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string
}
export interface ToolCallRecord {
id: string
name: string
/** Human-readable action description supplied by the tool runtime, when available. */
intent?: string
/** 已解析的参数(原始 JSON 字符串的解析结果,解析失败则保留原文) */
args: unknown
argsRaw: string
status: 'running' | 'ok' | 'error' | 'cancelled'
/** 完整的文本输出;summary 仅供折叠态摘要,不能替代原文。 */
output?: string
/** 展示用摘要(截断后的结果) */
summary?: string
durationMs?: number
/** Native structured result and renderer metadata are retained separately from text. */
result?: unknown
metadata?: Record<string, unknown>
}
export type MessagePart =
| { type: 'text'; text: string }
| { type: 'tool'; toolId: string }
| { type: 'reasoning'; id: string; text: string; finished?: boolean }
| { type: 'plan'; plan: PlanContent }
| { type: 'task'; task: TaskContent }
| { type: 'artifact'; artifact: ArtifactContent }
| { type: 'image'; id: string; url: string; alt?: string }
/** 面向模型暴露的工具描述(OpenAI function tool 格式) */
export interface ToolSpec {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown>
}
}
/** 网关配置:一个 OpenAI 兼容的上游 */
export interface GatewayConfig {
speechBaseUrl?: string
speechModel?: string
/** 上游 baseUrl,例如 https://api.deepseek.com/v1 */
baseUrl: string
/** 模型名,例如 deepseek-chat */
model: string
/** API Key(仅存在于主进程) */
apiKey: string
/** 采样温度 */
temperature: number
/** 单回合最大工具轮次 */
maxToolRounds: number
authMode?: 'api-key' | 'none'
maxOutputTokens?: number
}
/** 渲染进程可见的配置视图(不含 apiKey) */
export interface GatewayConfigView {
speechBaseUrl?: string
speechModel?: string
baseUrl: string
model: string
temperature: number
maxToolRounds: number
/** 是否已配置 Key(不回传明文) */
hasApiKey: boolean
/** Key 来源:safeStorage 加密 / 明文降级 */
keyStorage: 'encrypted' | 'plaintext' | 'none'
}
/** 主进程 → 渲染进程的流式事件 */
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
| { runId: string; type: 'started'; messageId: string }
| { runId: string; type: 'text'; delta: string }
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
| { runId: string; type: 'cancelled' }
| { runId: string; type: 'error'; message: string }
)
/** 一次会话的元信息 */
export interface SessionMeta {
id: string
title: string
createdAt: number
updatedAt: number
workspaceId?: string
cwd?: string
modelId?: string
expertId?: string | null
agentMode?: import('./conversation').AgentMode
thinkingLevel?: import('./workspaces').ThinkingLevel
}
/** 工作目录信息 */
export interface WorkspaceInfo {
root: string
}
/** 无边框窗口状态 */
export interface WindowState {
maximized: boolean
fullscreen: boolean
}
/** 应用信息(关于对话框用) */
export interface AppInfo {
name: string
version: string
electron: string
chrome: string
node: string
configDir: string
/** OMP 内核状态(未探测到时 available 为 false) */
omp: {
available: boolean
exePath: string | null
profile: string
model: string | null
}
}
@@ -0,0 +1,66 @@
/** Inert display projection from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb.
* workbuddy-provider-DkH2YFYM.js: canonicalizeToolName, unwrapDeferExecuteTool,
* TodoToolAdapter.writeTodosToTool and buildQuestionAnswerFromResult.
* Never changes the stored invocation or makes a tool executable. */
import type { ToolCallRecord } from './types'
const aliases: Record<string, string> = {
Read: 'read_file', Write: 'write_to_file', Edit: 'replace_in_file', MultiEdit: 'replace_in_file',
Glob: 'search_file', Grep: 'search_content', LS: 'list_dir', Bash: 'execute_command',
BashOutput: 'execute_command', KillShell: 'execute_command', TodoWrite: 'todo_write', Agent: 'task',
WebFetch: 'web_fetch', WebSearch: 'web_search', ImageGen: 'image_gen', VideoGen: 'video_gen',
Skill: 'use_skill', NotebookEdit: 'notebook_edit', TaskCreate: 'todo_write', TaskGet: 'task_get',
TaskUpdate: 'todo_write', TaskList: 'todo_write', TeamCreate: 'team_create', TeamDelete: 'team_delete',
SendMessage: 'send_message', AskUserQuestion: 'askuserquestion', EnterPlanMode: 'enter_plan_mode',
ExitPlanMode: 'exit_plan_mode', ToolSearch: 'tool_search', DeferExecuteTool: 'defer_execute_tool'
}
const record = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
export function canonicalizeWorkBuddyToolName(name: string): string {
if (name === 'automation_update' || name.endsWith('__automation_update')) return 'automation_update'
return aliases[name] ?? Object.entries(aliases).find(([alias]) => alias.toLowerCase() === name.toLowerCase())?.[1] ?? name
}
export function isWorkBuddyCancellationMarker(text: unknown): boolean {
return typeof text === 'string' && ['Interrupted by user', '[User Cancelled]'].includes(text.trim())
}
export function projectWorkBuddyHistoryTool(tool: ToolCallRecord): ToolCallRecord {
let name = canonicalizeWorkBuddyToolName(tool.name)
let args = record(tool.args)
if (name === 'defer_execute_tool' && typeof args?.toolName === 'string'
&& args.toolName.length <= 128 && /^[A-Za-z0-9_.-]+$/.test(args.toolName)) {
name = canonicalizeWorkBuddyToolName(args.toolName)
args = record(args.params) ?? args
}
const result = record(tool.result)
if (name === 'todo_write') {
const snapshot = record(result?.acpV2PlanSnapshot)
const entries = snapshot?.entries
// The recorded ACP plan contains the whole list, unlike TaskCreate/TaskUpdate input.
// Keep each historical snapshot; provider cross-request removal requires request identity.
if (Array.isArray(entries)) args = { ...args, todos: entries }
}
let metadata = tool.metadata
if (name === 'askuserquestion' && tool.status === 'ok' && !metadata?.questionAnswer && !metadata?.questionSkipped) {
const questions = args?.questions
const resultText = typeof result?.text === 'string' ? result.text
: typeof result?.content === 'string' ? result.content : tool.output ?? ''
if (Array.isArray(questions) && questions.length > 0) {
const answers = new Map<string, string>()
for (const line of resultText.split('\n')) {
const match = line.match(/^\s*(?:[\u00B7\u2022*-]\s*)?(.+?)\s*(?:\u2192|->)\s*(.+)\s*$/)
if (match) answers.set(match[1].trim(), match[2].trim())
}
metadata = { ...metadata, questionAnswer: { title: args?.title, questions: questions.map((value, index) => {
const question = record(value) ?? {}
const id = typeof question.id === 'string' && question.id ? question.id : `q-${index}`
const text = typeof question.question === 'string' ? question.question : ''
const answer = answers.get(id) ?? answers.get(id.replace('_', '-')) ?? answers.get(id.replace('-', '_')) ?? answers.get(text)
return { id, question: text, answers: answer ? [answer] : [] }
}) }, questionSkipped: false }
}
}
return { ...tool, name, args: args ?? tool.args, metadata }
}
+333
View File
@@ -0,0 +1,333 @@
/** Offline conversion of REA-exported WorkBuddy history. This module never executes tools. */
import type { ChatMessage, SessionMeta, ToolCallRecord } from './types'
import { isWorkBuddyCancellationMarker } from './workbuddy-history-projection'
export interface WorkBuddyImportOptions {
/** A fresh YCode session ID, unrelated to WorkBuddy's native session ID. */
sessionId: string
title: string
sourceSessionId: string
evidenceId: string
sourceSha256: string
importedAt: number
idPrefix?: string
/** Archive preserves every recorded branch. Visible applies explicit resend-fork notices. */
branchMode?: 'archive' | 'visible'
}
export interface WorkBuddyImportProvenance {
application: 'WorkBuddy'
sourceSessionId: string
evidenceId: string
sourceSha256: string
importedAt: number
/** Only inert history is imported; no native session or approval is resumed. */
executableStateImported: false
sourceEntries: Array<{ recordIndex: number; sourceId?: string; messageId?: string }>
branchMode: 'archive' | 'visible'
referencedAttachments: Array<{
messageId: string; attachmentId: string; sourceBlobId?: string; sourceBlobPath?: string
}>
}
export interface WorkBuddyImportResult {
meta: SessionMeta
messages: ChatMessage[]
provenance: WorkBuddyImportProvenance
warnings: string[]
stats: {
sourceRecords: number
importedMessages: number
toolCalls: number
unmatchedToolResults: number
unresolvedToolCalls: number
forksApplied: number
excludedBranchRecords: number
hiddenInjectedUserRecords: number
normalizedUserTexts: number
}
}
/** Fail the entire import on malformed JSON rather than silently dropping history. */
export function parseWorkBuddyJsonl(text: string): unknown[] {
const records: unknown[] = []
for (const [index, line] of text.replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
if (!line.trim()) continue
try {
const record: unknown = JSON.parse(line)
if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('Expected an object')
records.push(record)
} catch {
throw new Error(`WorkBuddy history contains invalid JSON at line ${index + 1}`)
}
}
return records
}
type RecordValue = Record<string, unknown>
const object = (value: unknown): RecordValue | undefined => value && typeof value === 'object' && !Array.isArray(value)
? value as RecordValue : undefined
const string = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
const time = (value: unknown, fallback: number): number => typeof value === 'number' && Number.isFinite(value) && value >= 0
? value : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value) && Number.isFinite(Number(value)) ? Number(value)
: typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : fallback
function textContent(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) return content.filter((value) => typeof value === 'string'
|| ['input_text', 'output_text', 'text'].includes(String(object(value)?.type))).map(textContent).join('\n')
const block = object(content)
return block && (block.type === 'input_text' || block.type === 'output_text' || block.type === 'text')
? string(block.text) ?? '' : ''
}
// Original common-DY2kXmis.js stripPromptContextXml, recovered by REA
// ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb.
export function stripWorkBuddyPromptContext(text: string): string {
return text.replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>\s*/g, '')
.replace(/<user_query>([\s\S]*?)<\/user_query>/g, '$1').trim()
}
// Original contexts-BMeWSUHk.js excludes complete task notifications from visible
// prompts. Stored isMeta records also append system execution instructions after
// the XML block; the import adapter hides this explicitly marked injected form.
function isInjectedTaskNotification(record: RecordValue, text: string): boolean {
return object(record.providerData)?.isMeta === true
&& /^\s*<task-notification>\s*[\s\S]*?<\/task-notification>/i.test(text)
}
function toolOutput(output: unknown): string {
if (typeof output === 'string') return output
if (Array.isArray(output)) return output.map(toolOutput).join('\n')
const block = object(output)
if (block && typeof block.text === 'string') return block.text
return output === undefined ? '' : JSON.stringify(output)
}
/**
* Converts the explicit JSONL record types observed in REA session evidence.
* Assistant text, reasoning and tool calls remain in source order within each turn.
* Provider payloads, rawContent, native branch IDs and live interactions are excluded.
*/
export function convertWorkBuddyTranscript(records: readonly unknown[], options: WorkBuddyImportOptions): WorkBuddyImportResult {
if (!options.sessionId || options.sessionId === options.sourceSessionId) throw new Error('A fresh YCode session ID is required')
if (!/^ev_[a-f0-9]+$/i.test(options.evidenceId) || !/^[a-f0-9]{64}$/i.test(options.sourceSha256)) {
throw new Error('REA evidence ID and source SHA-256 are required')
}
if (!Number.isFinite(options.importedAt)) throw new Error('A finite import timestamp is required')
const prefix = options.idPrefix ?? `wb-import-${options.sessionId}`
const warnings: string[] = []
const warningCounts = new Map<string, number>()
const warn = (warning: string): void => { warningCounts.set(warning, (warningCounts.get(warning) ?? 0) + 1) }
const branchMode = options.branchMode ?? 'archive'
let forksApplied = 0
let excludedBranchRecords = 0
let selected: Array<{ record: RecordValue; index: number }> = []
for (const [index, value] of records.entries()) {
const record = object(value)
if (!record) throw new Error(`WorkBuddy record ${index + 1} is not an object`)
if (record.type === 'resend-fork-notice') {
if (branchMode === 'visible') {
const cut = selected.findIndex((entry) => entry.record.id === record.editedUserItemId)
const targets = selected.filter((entry) => entry.record.id === record.editedUserItemId)
if (cut >= 0 && targets.length === 1 && targets[0].record.type === 'message' && targets[0].record.role === 'user') {
excludedBranchRecords += selected.length - cut
selected = selected.slice(0, cut)
forksApplied++
} else throw new Error(`Resend fork target was absent or ambiguous at source record ${index + 1}; visible branch import was stopped`)
} else warn('Resend forks were retained as full append-only history; this is not the currently visible branch')
}
selected.push({ record, index })
}
const messages: ChatMessage[] = []
const provenance: WorkBuddyImportProvenance = {
application: 'WorkBuddy', sourceSessionId: options.sourceSessionId,
evidenceId: options.evidenceId, sourceSha256: options.sourceSha256, importedAt: options.importedAt,
executableStateImported: false, sourceEntries: [], branchMode, referencedAttachments: []
}
const calls = new Map<string, Array<{ tool: ToolCallRecord; message: ChatMessage; createdAt: number }>>()
const orphanResults: Array<{ record: RecordValue; index: number }> = []
let assistant: ChatMessage | undefined
let lastUserAt: number | undefined
let assistantEnd = 0
let toolCount = 0
let unresolvedToolCalls = 0
let hiddenInjectedUserRecords = 0
let normalizedUserTexts = 0
let lastAssistantStatus: unknown
let lastAssistantError: string | undefined
let lastAssistantCancelled = false
const freshMessage = (role: 'user' | 'assistant', createdAt: number): ChatMessage => {
const message: ChatMessage = { id: `${prefix}-message-${messages.length + 1}`, role, content: '', createdAt, status: 'done' }
if (role === 'assistant') { message.parts = []; message.tools = [] }
messages.push(message)
return message
}
const finishAssistant = (): void => {
if (!assistant) return
assistant.content = (assistant.parts ?? []).filter((part) => part.type === 'text').map((part) => part.text).join('\n\n')
assistant.status = lastAssistantCancelled ? 'cancelled' : lastAssistantError ? 'error' : lastAssistantStatus === 'incomplete' || lastAssistantStatus === 'cancelled' ? 'cancelled'
: lastAssistantStatus === 'failed' || lastAssistantStatus === 'error' ? 'error' : 'done'
if (lastAssistantError && !lastAssistantCancelled) assistant.error = lastAssistantError
if (lastUserAt !== undefined && assistantEnd >= lastUserAt) assistant.turnDurationMs = assistantEnd - lastUserAt
assistant = undefined
lastAssistantStatus = undefined
lastAssistantError = undefined
lastAssistantCancelled = false
}
const getAssistant = (createdAt: number): ChatMessage => {
assistant ??= freshMessage('assistant', createdAt)
assistantEnd = Math.max(assistantEnd, createdAt)
return assistant
}
const applyResult = (record: RecordValue, index: number): boolean => {
const callId = string(record.callId)
const candidates = callId ? calls.get(callId) : undefined
const bound = candidates?.findLast((entry) => entry.tool.status === 'running')
if (!bound) return false
const { tool, message, createdAt } = bound
const nativeResult = object(object(record.providerData)?.toolResult)
tool.status = nativeResult?.error ? 'error' : record.status === 'completed' || record.status === 'success' ? 'ok'
: record.status === 'cancelled' || record.status === 'incomplete' ? 'cancelled' : 'error'
tool.output = toolOutput(record.output)
tool.summary = tool.output.length > 500 ? `${tool.output.slice(0, 500)}…` : tool.output
if (nativeResult) {
const displayResult: RecordValue = {}
for (const key of ['title', 'content', 'renderer', 'rawResponse', 'error', 'acpV2PlanSnapshot']) {
if (nativeResult[key] !== undefined) displayResult[key] = nativeResult[key]
}
tool.result = displayResult
// Existing specialized views read recorded raw responses from this display-only namespace.
if (nativeResult.rawResponse !== undefined) tool.metadata = {
...tool.metadata, _meta: { 'codebuddy.ai/rawResponse': nativeResult.rawResponse }
}
} else if (record.output !== undefined) tool.result = record.output
const finishedAt = time(record.timestamp, createdAt)
if (finishedAt >= createdAt) tool.durationMs = finishedAt - createdAt
tool.metadata = { ...tool.metadata, importedWorkBuddyResultId: string(record.id) }
provenance.sourceEntries.push({ recordIndex: index, sourceId: string(record.id), messageId: message.id })
assistantEnd = Math.max(assistantEnd, finishedAt)
return true
}
for (const { record, index } of selected) {
const timestamp = time(record.timestamp, options.importedAt)
const sourceId = string(record.id)
if (record.type === 'message') {
if (record.role !== 'user' && record.role !== 'assistant') { warn('Unsupported message roles were omitted'); continue }
if (record.role === 'user') {
const originalText = textContent(record.content)
if (isInjectedTaskNotification(record, originalText)) {
hiddenInjectedUserRecords++
provenance.sourceEntries.push({ recordIndex: index, sourceId })
warn('Injected isMeta task notifications were excluded from visible user prompts; full records remain in the REA archive')
continue
}
finishAssistant()
const message = freshMessage('user', timestamp)
message.content = stripWorkBuddyPromptContext(originalText)
if (message.content !== originalText) normalizedUserTexts++
lastUserAt = timestamp
for (const [blockIndex, value] of (Array.isArray(record.content) ? record.content : []).entries()) {
const block = object(value)
if (block?.type !== 'input_image') continue
const image = object(block.image)
const attachmentId = `${prefix}-attachment-${index}-${blockIndex}`
const name = string(block.original_filename) ?? string(image?.original_filename) ?? 'WorkBuddy image'
message.attachments ??= []
message.attachments.push({ id: attachmentId, name, mimeType: string(image?.mime) ?? 'image/png', size: typeof image?.size === 'number' ? image.size : 0 })
provenance.referencedAttachments.push({ messageId: message.id, attachmentId, sourceBlobId: string(image?.blob_id), sourceBlobPath: string(image?.blob_path) })
warn('Image attachment references were retained, but their bytes were not included in this JSONL export')
}
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
} else {
const message = getAssistant(timestamp)
const text = textContent(record.content)
lastAssistantCancelled = isWorkBuddyCancellationMarker(text)
if (text && !lastAssistantCancelled) message.parts!.push({ type: 'text', text })
lastAssistantStatus = record.status
lastAssistantError = string(object(object(record.providerData)?.error)?.message)
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
}
} else if (record.type === 'reasoning') {
const text = textContent(record.content)
if (text) {
const message = getAssistant(timestamp)
message.parts!.push({ type: 'reasoning', id: `${prefix}-reasoning-${index}`, text, finished: true })
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
} else if (record.rawContent !== undefined) warn('Reasoning rawContent was excluded; only explicit plain content was imported')
} else if (record.type === 'function_call') {
const message = getAssistant(timestamp)
const id = `${prefix}-tool-${++toolCount}`
const argsRaw = typeof record.arguments === 'string' ? record.arguments : JSON.stringify(record.arguments ?? {})
let args: unknown = argsRaw
try { args = JSON.parse(argsRaw) } catch { warn('Malformed tool arguments were preserved as original text') }
const callId = string(record.callId)
const tool: ToolCallRecord = {
id, name: string(record.name) ?? 'unknown', args, argsRaw, status: 'running',
metadata: { importedHistory: true, importedWorkBuddyCallId: callId, importedWorkBuddyEntryId: sourceId }
}
message.tools!.push(tool)
message.parts!.push({ type: 'tool', toolId: id })
if (callId) calls.set(callId, [...(calls.get(callId) ?? []), { tool, message, createdAt: timestamp }])
else warn('A tool call had no callId and could not be paired with a result')
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
} else if (record.type === 'function_call_result') {
if (!applyResult(record, index)) orphanResults.push({ record, index })
} else if (!['session-meta', 'file-history-snapshot', 'ai-title', 'resend-fork-notice'].includes(String(record.type))) {
warn('Unsupported source record types were omitted')
}
}
finishAssistant()
// Results normally follow calls. A second pass retains results exported out of order.
const unmatched = orphanResults.filter(({ record, index }) => !applyResult(record, index))
for (const message of messages) for (const tool of message.tools ?? []) if (tool.status === 'running') {
tool.status = 'cancelled'
unresolvedToolCalls++
}
if (unresolvedToolCalls) warn('Tools without terminal results were archived as cancelled; no work or approvals were resumed')
if (unmatched.length) warn('Tool results without matching callIds were not attached to an unrelated tool')
for (const [warning, count] of warningCounts) warnings.push(`${warning} (${count})`)
const createdAt = messages.length ? Math.min(...messages.map((message) => message.createdAt)) : options.importedAt
const updatedAt = Math.max(createdAt, options.importedAt)
return {
meta: { id: options.sessionId, title: options.title, createdAt, updatedAt }, messages, provenance, warnings,
stats: { sourceRecords: records.length, importedMessages: messages.length, toolCalls: toolCount, unmatchedToolResults: unmatched.length, unresolvedToolCalls, forksApplied, excludedBranchRecords, hiddenInjectedUserRecords, normalizedUserTexts }
}
}
export function convertWorkBuddyJsonl(text: string, options: WorkBuddyImportOptions): WorkBuddyImportResult {
return convertWorkBuddyTranscript(parseWorkBuddyJsonl(text), options)
}
/** Repair only the old importer duplicate cancellation tail. No source reimport or raw mutation.
* Caller persists the returned messages through its normal serialized session store. */
export function migrateImportedWorkBuddyMessages(
messages: ChatMessage[], provenance: unknown
): { messages: ChatMessage[]; changed: boolean; repairedCancellations: number } {
const origin = object(provenance)
if (origin?.application !== 'WorkBuddy' || origin.executableStateImported !== false
|| !/^ev_[a-f0-9]+$/i.test(String(origin.evidenceId))
|| !/^[a-f0-9]{64}$/i.test(String(origin.sourceSha256)) || !Array.isArray(origin.sourceEntries)) {
return { messages, changed: false, repairedCancellations: 0 }
}
const importedIds = new Set(origin.sourceEntries.flatMap(value => {
const entry = object(value)
return typeof entry?.sourceId === 'string' && typeof entry.messageId === 'string' ? [entry.messageId] : []
}))
let repairedCancellations = 0
const repaired = messages.map(message => {
if (message.role !== 'assistant' || !importedIds.has(message.id)
|| message.status !== 'error' || !isWorkBuddyCancellationMarker(message.error)) return message
const tail = message.parts?.at(-1)
if (tail?.type !== 'text' || !isWorkBuddyCancellationMarker(tail.text)) return message
const parts = message.parts!.slice(0, -1)
// Preserve user-edited content that no longer agrees with the old import projection.
const oldContent = message.parts!.filter(part => part.type === 'text').map(part => part.text).join('\n\n')
if (message.content !== oldContent) return message
const { error: _error, ...rest } = message
repairedCancellations++
return { ...rest, status: 'cancelled' as const, parts,
content: parts.filter(part => part.type === 'text').map(part => part.text).join('\n\n') }
})
return { messages: repairedCancellations ? repaired : messages, changed: repairedCancellations > 0, repairedCancellations }
}
+25
View File
@@ -0,0 +1,25 @@
export interface WorkspaceRecord {
id: string
name: string
root: string
createdAt: number
}
export interface WorkspaceRegistryView {
workspaces: WorkspaceRecord[]
selectedWorkspaceId: string | null
}
/**
* Reasoning effort for one session. `off` disables thinking entirely; the rest mirror OMP's
* `--thinking` levels. OMP additionally accepts `auto` and `minimal`, which this host does not
* currently surface in the selector.
*/
export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
export interface SessionContext {
workspaceId?: string
cwd?: string
modelId?: string
expertId?: string | null
agentMode?: import('./conversation').AgentMode
thinkingLevel?: ThinkingLevel
}