diff --git a/packages/agents/test/session-inspector.test.ts b/packages/agents/test/session-inspector.test.ts index 31cd875..c5ecf95 100644 --- a/packages/agents/test/session-inspector.test.ts +++ b/packages/agents/test/session-inspector.test.ts @@ -289,6 +289,90 @@ describe("session inspector", () => { expect(text).not.toContain("cost 0"); }); + it("hydrates OpenCode token usage from nested message info shape", () => { + const startedAt = Date.now(); + const state = buildSessionMessageState([ + { + timestamp: startedAt, + type: "message.updated", + data: { + payload: { + type: "message.updated", + properties: { + message: { + info: { + title: "Refactor live status parser", + modelId: "gpt-5.3-codex", + agentName: "build", + usage: { + total_tokens: 1024, + input_tokens: 200, + output_tokens: 120, + reasoning_tokens: 4, + cache_tokens: { + input_tokens: 700, + output_tokens: 0, + }, + }, + }, + }, + }, + }, + }, + }, + ]); + + expect(state.sessionTitle).toBeUndefined(); + expect(state.model).toBe("gpt-5.3-codex"); + expect(state.agent).toBe("build"); + expect(state.tokenUsage?.total).toBe(1024); + expect(state.tokenUsage?.cacheRead).toBe(700); + }); + + it("does not clear token usage when later message.updated has empty usage object", () => { + const startedAt = Date.now(); + const state = buildSessionMessageState([ + { + timestamp: startedAt, + type: "message.updated", + data: { + payload: { + type: "message.updated", + properties: { + info: { + modelID: "gpt-5.3-codex", + tokens: { + total: 2048, + input: 500, + output: 200, + reasoning: 10, + cache: { read: 1338, write: 0 }, + }, + }, + }, + }, + }, + }, + { + timestamp: startedAt + 1, + type: "message.updated", + data: { + payload: { + type: "message.updated", + properties: { + info: { + tokens: {}, + }, + }, + }, + }, + }, + ]); + + expect(state.tokenUsage?.total).toBe(2048); + expect(state.tokenUsage?.input).toBe(500); + }); + it("parses todo.updated aliases from wrapped payload events", () => { const now = Date.now(); const state = buildSessionMessageState([ diff --git a/packages/config/dashboard-config.ts b/packages/config/dashboard-config.ts index 5185262..0621e9c 100644 --- a/packages/config/dashboard-config.ts +++ b/packages/config/dashboard-config.ts @@ -3,6 +3,7 @@ import { parseStatusMessageFrequencyMs, type StatusMessageFrequencyMs, } from "./status-message-frequency"; +import { isAgentProviderId, type AgentProviderId } from "@/shared/agent-provider"; export type DashboardConfig = { completeOnboarding: boolean; @@ -69,7 +70,7 @@ export type DashboardConfig = { channelDetails: { id: string; name: string; - agentProvider?: "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"; + agentProvider?: AgentProviderId; model: string; workingDirectory: string; baseBranch: string; @@ -152,16 +153,10 @@ const asGitStrategy = ( const asStatus = (value: unknown): DashboardConfig["workspaces"][number]["status"] => value === "paused" ? "paused" : "active"; -const KNOWN_AGENT_PROVIDERS = new Set>(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]); - function isKnownAgentProvider( value: string ): value is NonNullable { - return KNOWN_AGENT_PROVIDERS.has(value as NonNullable< - DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"] - >); + return isAgentProviderId(value); } const asAgentProvider = ( diff --git a/packages/config/local/ode-channel.ts b/packages/config/local/ode-channel.ts new file mode 100644 index 0000000..f01a4f2 --- /dev/null +++ b/packages/config/local/ode-channel.ts @@ -0,0 +1,131 @@ +import { normalizeCwd } from "../paths"; +import { isAgentProviderId } from "@/shared/agent-provider"; +import { + type AgentProvider, + type ChannelDetail, + type WorkspaceConfig, +} from "./ode-schema"; +import { + loadOdeConfig, + normalizeBaseBranch, + updateOdeConfig, +} from "./ode-store"; + +export type ChannelCwdInfo = { + cwd: string; + workingDirectory: string | null; + hasCustomCwd: boolean; +}; + +export function getDefaultCwd(): string { + return normalizeCwd(process.cwd()); +} + +function getWorkspaces(): WorkspaceConfig[] { + return loadOdeConfig().workspaces; +} + +export function getChannelDetails(channelId: string): ChannelDetail | null { + for (const workspace of getWorkspaces()) { + const match = workspace.channelDetails.find((channel) => channel.id === channelId); + if (match) return match; + } + return null; +} + +export function resolveChannelCwd(channelId: string): ChannelCwdInfo { + const channel = getChannelDetails(channelId); + const workingDirectory = channel?.workingDirectory?.trim(); + const normalized = workingDirectory && workingDirectory.length > 0 + ? normalizeCwd(workingDirectory) + : null; + return { + cwd: normalized ?? getDefaultCwd(), + workingDirectory: normalized, + hasCustomCwd: Boolean(normalized), + }; +} + +export function setChannelCwd(channelId: string, cwd: string): void { + updateChannel(channelId, (channel) => ({ + ...channel, + workingDirectory: normalizeCwd(cwd), + })); +} + +export function setChannelWorkingDirectory(channelId: string, workingDirectory: string | null): void { + const normalized = workingDirectory && workingDirectory.trim().length > 0 + ? normalizeCwd(workingDirectory) + : ""; + updateChannel(channelId, (channel) => ({ + ...channel, + workingDirectory: normalized, + })); +} + +export function getChannelBaseBranch(channelId: string): string { + return normalizeBaseBranch(getChannelDetails(channelId)?.baseBranch); +} + +export function setChannelBaseBranch(channelId: string, baseBranch: string | null): void { + const normalized = normalizeBaseBranch(baseBranch); + updateChannel(channelId, (channel) => ({ + ...channel, + baseBranch: normalized, + })); +} + +export function getChannelSystemMessage(channelId: string): string | null { + return getChannelDetails(channelId)?.channelSystemMessage ?? null; +} + +export function setChannelSystemMessage(channelId: string, channelSystemMessage: string | null): void { + const normalized = channelSystemMessage?.trim() ?? ""; + updateChannel(channelId, (channel) => ({ + ...channel, + channelSystemMessage: normalized, + })); +} + +export function getChannelModel(channelId: string): string | null { + return getChannelDetails(channelId)?.model ?? null; +} + +export function getChannelAgentProvider(channelId: string): AgentProvider { + const provider = getChannelDetails(channelId)?.agentProvider; + return isAgentProviderId(provider) ? provider : "opencode"; +} + +export function setChannelModel(channelId: string, model: string): void { + updateChannel(channelId, (channel) => ({ ...channel, model })); +} + +export function setChannelAgentProvider( + channelId: string, + agentProvider: AgentProvider +): void { + updateChannel(channelId, (channel) => ({ ...channel, agentProvider })); +} + +function updateChannel( + channelId: string, + updater: (channel: ChannelDetail) => ChannelDetail +): void { + let updated = false; + updateOdeConfig((config) => { + const workspaces = config.workspaces.map((workspace) => { + const channelDetails = workspace.channelDetails.map((channel) => { + if (channel.id !== channelId) return channel; + updated = true; + return updater(channel); + }); + return { ...workspace, channelDetails }; + }); + + if (!updated) { + throw new Error("Channel not found in ~/.config/ode/ode.json"); + } + + return { ...config, workspaces }; + }); +} diff --git a/packages/config/local/ode-schema.ts b/packages/config/local/ode-schema.ts new file mode 100644 index 0000000..496c0a2 --- /dev/null +++ b/packages/config/local/ode-schema.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import { AGENT_PROVIDERS } from "@/shared/agent-provider"; +import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "../status-message-frequency"; + +const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000; + +const userSchema = z.object({ + name: z.string().optional().default(""), + email: z.string().optional().default(""), + initials: z.string().optional().default(""), + avatar: z.string().optional().default(""), + gitStrategy: z.enum(["default", "worktree"]).optional().default("worktree"), + defaultStatusMessageFormat: z.enum(["minimum", "medium", "aggressive"]).optional().default("medium"), + defaultMessageFrequency: z.enum(["minimum", "medium", "aggressive"]).optional(), + messageUpdateIntervalMs: z.number().optional(), + IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS), +}); + +export const agentProviderSchema = z.enum(AGENT_PROVIDERS); + +const agentsSchema = z.object({ + opencode: z.object({ + enabled: z.boolean().optional().default(true), + models: z.array(z.string()).optional().default([]), + }).optional().default({ enabled: true, models: [] }), + claudecode: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), + codex: z.object({ + enabled: z.boolean().optional().default(true), + models: z.array(z.string()).optional().default([]), + }).optional().default({ enabled: true, models: [] }), + kimi: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), + kiro: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), + kilo: z.object({ + enabled: z.boolean().optional().default(true), + models: z.array(z.string()).optional().default([]), + }).optional().default({ enabled: true, models: [] }), + qwen: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), + goose: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), + gemini: z.object({ + enabled: z.boolean().optional().default(true), + }).optional().default({ enabled: true }), +}).optional().default({ + opencode: { enabled: true, models: [] }, + claudecode: { enabled: true }, + codex: { enabled: true, models: [] }, + kimi: { enabled: true }, + kiro: { enabled: true }, + kilo: { enabled: true, models: [] }, + qwen: { enabled: true }, + goose: { enabled: true }, + gemini: { enabled: true }, +}); + +const channelDetailSchema = z.object({ + id: z.string(), + name: z.string(), + agentProvider: z.preprocess( + (value) => (value === "claude" ? "claudecode" : value), + agentProviderSchema.optional().default("opencode") + ), + model: z.string().optional().default(""), + workingDirectory: z.string().optional().default(""), + baseBranch: z.string().optional().default("main"), + channelSystemMessage: z.string().optional().default(""), +}); + +const updateSchema = z.object({ + autoUpgrade: z.boolean().optional().default(true), + checkIntervalMs: z.number().optional().default(DEFAULT_UPDATE_INTERVAL_MS), +}); + +const workspaceSchema = z.object({ + id: z.string(), + type: z.enum(["slack", "discord", "lark"]).optional().default("slack"), + name: z.string().optional().default(""), + domain: z.string().optional().default(""), + status: z.enum(["active", "paused"]).optional().default("active"), + channels: z.number().optional().default(0), + members: z.number().optional().default(0), + lastSync: z.string().optional().default(""), + slackAppToken: z.string().optional().default(""), + slackBotToken: z.string().optional().default(""), + discordBotToken: z.string().optional().default(""), + larkAppKey: z.string().optional().default(""), + larkAppId: z.string().optional().default(""), + larkAppSecret: z.string().optional().default(""), + channelDetails: z.array(channelDetailSchema).optional().default([]), +}); + +export const odeConfigSchema = z.object({ + user: userSchema, + githubInfos: z + .record( + z.string(), + z.object({ + token: z.string().optional().default(""), + gitName: z.string().optional().default(""), + gitEmail: z.string().optional().default(""), + }) + ) + .optional() + .default({}), + agents: agentsSchema, + completeOnboarding: z.boolean().optional().default(false), + workspaces: z.array(workspaceSchema), + updates: updateSchema.optional().default({ + autoUpgrade: true, + checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS, + }), +}); + +export type ChannelDetail = z.infer; +export type WorkspaceConfig = z.infer; +export type AgentProvider = z.infer; +export type AgentsConfig = z.infer; +export type UpdateConfig = z.infer; +export type OdeConfig = z.infer; +export type UserConfig = z.infer; diff --git a/packages/config/local/ode-store.ts b/packages/config/local/ode-store.ts new file mode 100644 index 0000000..429d1c3 --- /dev/null +++ b/packages/config/local/ode-store.ts @@ -0,0 +1,210 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "../status-message-frequency"; +import { + odeConfigSchema, + type OdeConfig, +} from "./ode-schema"; + +const existsSync = fs.existsSync; +const mkdirSync = fs.mkdirSync; +const readFileSync = fs.readFileSync; +const writeFileSync = fs.writeFileSync; +const join = typeof path.join === "function" ? path.join : (...parts: string[]) => parts.join("/"); +const homedir = typeof os.homedir === "function" ? os.homedir : () => ""; + +const XDG_CONFIG_HOME = join(homedir(), ".config"); +const ODE_CONFIG_DIR = join(XDG_CONFIG_HOME, "ode"); +export const ODE_CONFIG_FILE = join(ODE_CONFIG_DIR, "ode.json"); + +const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000; +const MIN_UPDATE_INTERVAL_MS = 5 * 60 * 1000; +const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS; +const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250; + +let cachedConfig: OdeConfig | null = null; + +const EMPTY_TEMPLATE: OdeConfig = { + user: { + name: "", + email: "", + initials: "", + avatar: "", + gitStrategy: "worktree", + defaultStatusMessageFormat: "medium", + IM_MESSAGE_UPDATE_INTERVAL_MS: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS, + }, + githubInfos: {}, + agents: { + opencode: { enabled: true, models: [] }, + claudecode: { enabled: true }, + codex: { enabled: true, models: [] }, + kimi: { enabled: true }, + kiro: { enabled: true }, + kilo: { enabled: true, models: [] }, + qwen: { enabled: true }, + goose: { enabled: true }, + gemini: { enabled: true }, + }, + completeOnboarding: false, + workspaces: [], + updates: { + autoUpgrade: true, + checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS, + }, +}; + +function ensureConfigDir(): void { + if (!existsSync(ODE_CONFIG_DIR)) { + mkdirSync(ODE_CONFIG_DIR, { recursive: true }); + } +} + +function ensureConfigFile(): void { + if (existsSync(ODE_CONFIG_FILE)) return; + ensureConfigDir(); + writeFileSync(ODE_CONFIG_FILE, JSON.stringify(EMPTY_TEMPLATE, null, 2)); +} + +export function normalizeBaseBranch(baseBranch: string | null | undefined): string { + const normalized = baseBranch?.trim(); + return normalized && normalized.length > 0 ? normalized : "main"; +} + +function normalizeConfig(config: OdeConfig): OdeConfig { + const { + defaultMessageFrequency: _deprecatedMessageFrequency, + messageUpdateIntervalMs: _deprecatedMessageUpdateIntervalMs, + ...normalizedUser + } = config.user; + const statusMessageFormat = config.user.defaultStatusMessageFormat + ?? config.user.defaultMessageFrequency + ?? "medium"; + const normalizedFrequency = statusMessageFormat; + const normalizedGitStrategy = + config.user.gitStrategy === "default" ? "default" : "worktree"; + const messageUpdateIntervalCandidate = + config.user.IM_MESSAGE_UPDATE_INTERVAL_MS + ?? config.user.messageUpdateIntervalMs + ?? DEFAULT_MESSAGE_UPDATE_INTERVAL_MS; + const normalizedMessageUpdateInterval = + Number.isFinite(messageUpdateIntervalCandidate) && messageUpdateIntervalCandidate > 0 + ? Math.max(messageUpdateIntervalCandidate, MIN_MESSAGE_UPDATE_INTERVAL_MS) + : DEFAULT_MESSAGE_UPDATE_INTERVAL_MS; + const intervalCandidate = config.updates?.checkIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS; + const normalizedInterval = + Number.isFinite(intervalCandidate) && intervalCandidate > 0 + ? Math.max(intervalCandidate, MIN_UPDATE_INTERVAL_MS) + : DEFAULT_UPDATE_INTERVAL_MS; + const autoUpgrade = config.updates?.autoUpgrade ?? true; + const opencodeModels = Array.from(new Set((config.agents?.opencode?.models ?? []) + .map((model) => model.trim()) + .filter(Boolean))); + const codexModels = Array.from(new Set((config.agents?.codex?.models ?? []) + .map((model) => model.trim()) + .filter(Boolean))); + const kiloModels = Array.from(new Set((config.agents?.kilo?.models ?? []) + .map((model) => model.trim()) + .filter(Boolean))); + const completeOnboarding = config.completeOnboarding === true; + const workspaces = config.workspaces.map((workspace) => ({ + ...workspace, + type: + workspace.type === "discord" + ? "discord" as const + : workspace.type === "lark" + ? "lark" as const + : "slack" as const, + channelDetails: workspace.channelDetails.map((channel) => ({ + ...channel, + baseBranch: normalizeBaseBranch(channel.baseBranch), + })), + })); + return { + ...config, + user: { + ...normalizedUser, + gitStrategy: normalizedGitStrategy, + defaultStatusMessageFormat: normalizedFrequency, + IM_MESSAGE_UPDATE_INTERVAL_MS: normalizedMessageUpdateInterval, + }, + updates: { + autoUpgrade, + checkIntervalMs: normalizedInterval, + }, + agents: { + opencode: { + enabled: config.agents?.opencode?.enabled ?? true, + models: opencodeModels, + }, + claudecode: { + enabled: config.agents?.claudecode?.enabled ?? true, + }, + codex: { + enabled: config.agents?.codex?.enabled ?? true, + models: codexModels, + }, + kimi: { + enabled: config.agents?.kimi?.enabled ?? true, + }, + kiro: { + enabled: config.agents?.kiro?.enabled ?? true, + }, + kilo: { + enabled: config.agents?.kilo?.enabled ?? true, + models: kiloModels, + }, + qwen: { + enabled: config.agents?.qwen?.enabled ?? true, + }, + goose: { + enabled: config.agents?.goose?.enabled ?? true, + }, + gemini: { + enabled: config.agents?.gemini?.enabled ?? true, + }, + }, + completeOnboarding, + workspaces, + }; +} + +export function loadOdeConfig(): OdeConfig { + if (cachedConfig) return cachedConfig; + + ensureConfigFile(); + + if (!existsSync(ODE_CONFIG_FILE)) { + cachedConfig = normalizeConfig(EMPTY_TEMPLATE); + return cachedConfig; + } + + try { + const raw = readFileSync(ODE_CONFIG_FILE, "utf-8"); + const parsedJson = JSON.parse(raw) as Record; + const parsed = odeConfigSchema.safeParse(parsedJson); + const base = parsed.success ? parsed.data : EMPTY_TEMPLATE; + cachedConfig = normalizeConfig(base); + return cachedConfig; + } catch { + cachedConfig = normalizeConfig(EMPTY_TEMPLATE); + return cachedConfig; + } +} + +export function invalidateOdeConfigCache(): void { + cachedConfig = null; +} + +export function saveOdeConfig(config: OdeConfig): void { + ensureConfigDir(); + cachedConfig = normalizeConfig(config); + writeFileSync(ODE_CONFIG_FILE, JSON.stringify(cachedConfig, null, 2)); +} + +export function updateOdeConfig(updater: (config: OdeConfig) => OdeConfig): OdeConfig { + const next = updater(structuredClone(loadOdeConfig())); + saveOdeConfig(next); + return loadOdeConfig(); +} diff --git a/packages/config/local/ode.ts b/packages/config/local/ode.ts index 4df4337..caf22c1 100644 --- a/packages/config/local/ode.ts +++ b/packages/config/local/ode.ts @@ -1,8 +1,3 @@ -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { z } from "zod"; -import { normalizeCwd } from "../paths"; import { sanitizeDashboardConfig, type DashboardConfig, @@ -12,350 +7,53 @@ import { parseStatusMessageFrequencyMs, type StatusMessageFrequencyMs, } from "../status-message-frequency"; - -const existsSync = fs.existsSync; -const mkdirSync = fs.mkdirSync; -const readFileSync = fs.readFileSync; -const writeFileSync = fs.writeFileSync; -const join = typeof path.join === "function" ? path.join : (...parts: string[]) => parts.join("/"); -const homedir = typeof os.homedir === "function" ? os.homedir : () => ""; - -const XDG_CONFIG_HOME = join(homedir(), ".config"); -const ODE_CONFIG_DIR = join(XDG_CONFIG_HOME, "ode"); -export const ODE_CONFIG_FILE = join(ODE_CONFIG_DIR, "ode.json"); - -const userSchema = z.object({ - name: z.string().optional().default(""), - email: z.string().optional().default(""), - initials: z.string().optional().default(""), - avatar: z.string().optional().default(""), - gitStrategy: z.enum(["default", "worktree"]).optional().default("worktree"), - defaultStatusMessageFormat: z.enum([ - "minimum", - "medium", - "aggressive", - "low", - "high", - ]).optional().default("medium"), - defaultMessageFrequency: z.enum([ - "minimum", - "medium", - "aggressive", - "low", - "high", - ]).optional(), - messageUpdateIntervalMs: z.number().optional(), - IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS), -}); - -const agentProviderSchema = z.enum(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]); - -const agentsSchema = z.object({ - opencode: z.object({ - enabled: z.boolean().optional().default(true), - models: z.array(z.string()).optional().default([]), - }).optional().default({ enabled: true, models: [] }), - claudecode: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), - codex: z.object({ - enabled: z.boolean().optional().default(true), - models: z.array(z.string()).optional().default([]), - }).optional().default({ enabled: true, models: [] }), - kimi: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), - kiro: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), - kilo: z.object({ - enabled: z.boolean().optional().default(true), - models: z.array(z.string()).optional().default([]), - }).optional().default({ enabled: true, models: [] }), - qwen: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), - goose: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), - gemini: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), -}).optional().default({ - opencode: { enabled: true, models: [] }, - claudecode: { enabled: true }, - codex: { enabled: true, models: [] }, - kimi: { enabled: true }, - kiro: { enabled: true }, - kilo: { enabled: true, models: [] }, - qwen: { enabled: true }, - goose: { enabled: true }, - gemini: { enabled: true }, -}); - -const channelDetailSchema = z.object({ - id: z.string(), - name: z.string(), - agentProvider: z.preprocess( - (value) => (value === "claude" ? "claudecode" : value), - agentProviderSchema.optional().default("opencode") - ), - model: z.string().optional().default(""), - workingDirectory: z.string().optional().default(""), - baseBranch: z.string().optional().default("main"), - channelSystemMessage: z.string().optional().default(""), -}); +import { + type WorkspaceConfig, + type AgentProvider, + type AgentsConfig, + type UpdateConfig, + type OdeConfig, +} from "./ode-schema"; +import { + ODE_CONFIG_FILE, + loadOdeConfig, + saveOdeConfig, + updateOdeConfig, + invalidateOdeConfigCache, +} from "./ode-store"; + +export type { + ChannelDetail, + WorkspaceConfig, + AgentProvider, + AgentsConfig, + UpdateConfig, + OdeConfig, + UserConfig, +} from "./ode-schema"; +export { ODE_CONFIG_FILE } from "./ode-store"; +export { invalidateOdeConfigCache, loadOdeConfig, saveOdeConfig, updateOdeConfig } from "./ode-store"; +export { + getDefaultCwd, + getChannelDetails, + resolveChannelCwd, + setChannelCwd, + setChannelWorkingDirectory, + getChannelBaseBranch, + setChannelBaseBranch, + getChannelSystemMessage, + setChannelSystemMessage, + getChannelModel, + getChannelAgentProvider, + setChannelModel, + setChannelAgentProvider, + type ChannelCwdInfo, +} from "./ode-channel"; const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000; const MIN_UPDATE_INTERVAL_MS = 5 * 60 * 1000; -const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS; -const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250; export const DEFAULT_CODEX_MODEL = "gpt-5.3-codex"; -const updateSchema = z.object({ - autoUpgrade: z.boolean().optional().default(true), - checkIntervalMs: z.number().optional().default(DEFAULT_UPDATE_INTERVAL_MS), -}); - -const workspaceSchema = z.object({ - id: z.string(), - type: z.enum(["slack", "discord", "lark"]).optional().default("slack"), - name: z.string().optional().default(""), - domain: z.string().optional().default(""), - status: z.enum(["active", "paused"]).optional().default("active"), - channels: z.number().optional().default(0), - members: z.number().optional().default(0), - lastSync: z.string().optional().default(""), - slackAppToken: z.string().optional().default(""), - slackBotToken: z.string().optional().default(""), - discordBotToken: z.string().optional().default(""), - larkAppKey: z.string().optional().default(""), - larkAppId: z.string().optional().default(""), - larkAppSecret: z.string().optional().default(""), - channelDetails: z.array(channelDetailSchema).optional().default([]), -}); - -const odeConfigSchema = z.object({ - user: userSchema, - githubInfos: z - .record( - z.string(), - z.object({ - token: z.string().optional().default(""), - gitName: z.string().optional().default(""), - gitEmail: z.string().optional().default(""), - }) - ) - .optional() - .default({}), - agents: agentsSchema, - completeOnboarding: z.boolean().optional().default(false), - workspaces: z.array(workspaceSchema), - updates: updateSchema.optional().default({ - autoUpgrade: true, - checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS, - }), -}); - -export type ChannelDetail = z.infer; -export type WorkspaceConfig = z.infer; -export type AgentProvider = z.infer; -export type AgentsConfig = z.infer; -export type UpdateConfig = z.infer; -export type OdeConfig = z.infer; -export type UserConfig = z.infer; - -let cachedConfig: OdeConfig | null = null; - -const EMPTY_TEMPLATE: OdeConfig = { - user: { - name: "", - email: "", - initials: "", - avatar: "", - gitStrategy: "worktree", - defaultStatusMessageFormat: "medium", - IM_MESSAGE_UPDATE_INTERVAL_MS: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS, - }, - githubInfos: {}, - agents: { - opencode: { enabled: true, models: [] }, - claudecode: { enabled: true }, - codex: { enabled: true, models: [] }, - kimi: { enabled: true }, - kiro: { enabled: true }, - kilo: { enabled: true, models: [] }, - qwen: { enabled: true }, - goose: { enabled: true }, - gemini: { enabled: true }, - }, - completeOnboarding: false, - workspaces: [], - updates: { - autoUpgrade: true, - checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS, - }, -}; - -function ensureConfigDir(): void { - if (!existsSync(ODE_CONFIG_DIR)) { - mkdirSync(ODE_CONFIG_DIR, { recursive: true }); - } -} - -function ensureConfigFile(): void { - if (existsSync(ODE_CONFIG_FILE)) return; - ensureConfigDir(); - writeFileSync(ODE_CONFIG_FILE, JSON.stringify(EMPTY_TEMPLATE, null, 2)); -} - -function normalizeBaseBranch(baseBranch: string | null | undefined): string { - const normalized = baseBranch?.trim(); - return normalized && normalized.length > 0 ? normalized : "main"; -} - -function normalizeConfig(config: OdeConfig): OdeConfig { - const { - defaultMessageFrequency: _deprecatedMessageFrequency, - messageUpdateIntervalMs: _deprecatedMessageUpdateIntervalMs, - ...normalizedUser - } = config.user; - const statusMessageFormat = config.user.defaultStatusMessageFormat - ?? config.user.defaultMessageFrequency - ?? "medium"; - const normalizedFrequency = - statusMessageFormat === "low" - ? "minimum" - : statusMessageFormat === "high" - ? "aggressive" - : statusMessageFormat; - const normalizedGitStrategy = - config.user.gitStrategy === "default" ? "default" : "worktree"; - const messageUpdateIntervalCandidate = - config.user.IM_MESSAGE_UPDATE_INTERVAL_MS - ?? config.user.messageUpdateIntervalMs - ?? DEFAULT_MESSAGE_UPDATE_INTERVAL_MS; - const normalizedMessageUpdateInterval = - Number.isFinite(messageUpdateIntervalCandidate) && messageUpdateIntervalCandidate > 0 - ? Math.max(messageUpdateIntervalCandidate, MIN_MESSAGE_UPDATE_INTERVAL_MS) - : DEFAULT_MESSAGE_UPDATE_INTERVAL_MS; - const intervalCandidate = config.updates?.checkIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS; - const normalizedInterval = - Number.isFinite(intervalCandidate) && intervalCandidate > 0 - ? Math.max(intervalCandidate, MIN_UPDATE_INTERVAL_MS) - : DEFAULT_UPDATE_INTERVAL_MS; - const autoUpgrade = config.updates?.autoUpgrade ?? true; - const opencodeModels = Array.from(new Set((config.agents?.opencode?.models ?? []) - .map((model) => model.trim()) - .filter(Boolean))); - const codexModels = Array.from(new Set((config.agents?.codex?.models ?? []) - .map((model) => model.trim()) - .filter(Boolean))); - const kiloModels = Array.from(new Set((config.agents?.kilo?.models ?? []) - .map((model) => model.trim()) - .filter(Boolean))); - const completeOnboarding = config.completeOnboarding === true; - const workspaces = config.workspaces.map((workspace) => ({ - ...workspace, - type: - workspace.type === "discord" - ? "discord" as const - : workspace.type === "lark" - ? "lark" as const - : "slack" as const, - channelDetails: workspace.channelDetails.map((channel) => ({ - ...channel, - baseBranch: normalizeBaseBranch(channel.baseBranch), - })), - })); - return { - ...config, - user: { - ...normalizedUser, - gitStrategy: normalizedGitStrategy, - defaultStatusMessageFormat: normalizedFrequency, - IM_MESSAGE_UPDATE_INTERVAL_MS: normalizedMessageUpdateInterval, - }, - updates: { - autoUpgrade, - checkIntervalMs: normalizedInterval, - }, - agents: { - opencode: { - enabled: config.agents?.opencode?.enabled ?? true, - models: opencodeModels, - }, - claudecode: { - enabled: config.agents?.claudecode?.enabled ?? true, - }, - codex: { - enabled: config.agents?.codex?.enabled ?? true, - models: codexModels, - }, - kimi: { - enabled: config.agents?.kimi?.enabled ?? true, - }, - kiro: { - enabled: config.agents?.kiro?.enabled ?? true, - }, - kilo: { - enabled: config.agents?.kilo?.enabled ?? true, - models: kiloModels, - }, - qwen: { - enabled: config.agents?.qwen?.enabled ?? true, - }, - goose: { - enabled: config.agents?.goose?.enabled ?? true, - }, - gemini: { - enabled: config.agents?.gemini?.enabled ?? true, - }, - }, - completeOnboarding, - workspaces, - }; -} - -export function loadOdeConfig(): OdeConfig { - if (cachedConfig) return cachedConfig; - - ensureConfigFile(); - - if (!existsSync(ODE_CONFIG_FILE)) { - cachedConfig = normalizeConfig(EMPTY_TEMPLATE); - return cachedConfig; - } - - try { - const raw = readFileSync(ODE_CONFIG_FILE, "utf-8"); - const parsedJson = JSON.parse(raw) as Record; - const parsed = odeConfigSchema.safeParse(parsedJson); - const base = parsed.success ? parsed.data : EMPTY_TEMPLATE; - cachedConfig = normalizeConfig(base); - return cachedConfig; - } catch { - cachedConfig = normalizeConfig(EMPTY_TEMPLATE); - return cachedConfig; - } -} - -export function invalidateOdeConfigCache(): void { - cachedConfig = null; -} - -export function saveOdeConfig(config: OdeConfig): void { - ensureConfigDir(); - cachedConfig = normalizeConfig(config); - writeFileSync(ODE_CONFIG_FILE, JSON.stringify(cachedConfig, null, 2)); -} - -export function updateOdeConfig(updater: (config: OdeConfig) => OdeConfig): OdeConfig { - const next = updater(structuredClone(loadOdeConfig())); - saveOdeConfig(next); - return loadOdeConfig(); -} - function toDashboardConfig(config: OdeConfig): DashboardConfig { const defaultStatusMessageFormat = config.user.defaultStatusMessageFormat === "aggressive" || config.user.defaultStatusMessageFormat === "minimum" @@ -608,18 +306,6 @@ export function getLarkTargetChannels(): string[] | null { return ids.length > 0 ? ids : null; } -export function getDefaultCwd(): string { - return normalizeCwd(process.cwd()); -} - -export function getChannelDetails(channelId: string): ChannelDetail | null { - for (const workspace of getWorkspaces()) { - const match = workspace.channelDetails.find((channel) => channel.id === channelId); - if (match) return match; - } - return null; -} - export type GitHubInfo = { token?: string; gitName?: string; @@ -704,107 +390,3 @@ export function clearGitHubInfoForUser(userId: string): void { return { ...config, githubInfos }; }); } - -export type ChannelCwdInfo = { - cwd: string; - workingDirectory: string | null; - hasCustomCwd: boolean; -}; - -export function resolveChannelCwd(channelId: string): ChannelCwdInfo { - const channel = getChannelDetails(channelId); - const workingDirectory = channel?.workingDirectory?.trim(); - const normalized = workingDirectory && workingDirectory.length > 0 - ? normalizeCwd(workingDirectory) - : null; - return { - cwd: normalized ?? getDefaultCwd(), - workingDirectory: normalized, - hasCustomCwd: Boolean(normalized), - }; -} - -export function setChannelCwd(channelId: string, cwd: string): void { - updateChannel(channelId, (channel) => ({ - ...channel, - workingDirectory: normalizeCwd(cwd), - })); -} - -export function setChannelWorkingDirectory(channelId: string, workingDirectory: string | null): void { - const normalized = workingDirectory && workingDirectory.trim().length > 0 - ? normalizeCwd(workingDirectory) - : ""; - updateChannel(channelId, (channel) => ({ - ...channel, - workingDirectory: normalized, - })); -} - -export function getChannelBaseBranch(channelId: string): string { - return normalizeBaseBranch(getChannelDetails(channelId)?.baseBranch); -} - -export function setChannelBaseBranch(channelId: string, baseBranch: string | null): void { - const normalized = normalizeBaseBranch(baseBranch); - updateChannel(channelId, (channel) => ({ - ...channel, - baseBranch: normalized, - })); -} - -export function getChannelSystemMessage(channelId: string): string | null { - return getChannelDetails(channelId)?.channelSystemMessage ?? null; -} - -export function setChannelSystemMessage(channelId: string, channelSystemMessage: string | null): void { - const normalized = channelSystemMessage?.trim() ?? ""; - updateChannel(channelId, (channel) => ({ - ...channel, - channelSystemMessage: normalized, - })); -} - -export function getChannelModel(channelId: string): string | null { - return getChannelDetails(channelId)?.model ?? null; -} - -export function getChannelAgentProvider(channelId: string): AgentProvider { - const provider = getChannelDetails(channelId)?.agentProvider; - if (provider === "claudecode" || provider === "codex" || provider === "kimi" || provider === "kiro" || provider === "kilo" || provider === "qwen" || provider === "goose" || provider === "gemini") return provider; - return "opencode"; -} - -export function setChannelModel(channelId: string, model: string): void { - updateChannel(channelId, (channel) => ({ ...channel, model })); -} - -export function setChannelAgentProvider( - channelId: string, - agentProvider: AgentProvider -): void { - updateChannel(channelId, (channel) => ({ ...channel, agentProvider })); -} - -function updateChannel( - channelId: string, - updater: (channel: ChannelDetail) => ChannelDetail -): void { - let updated = false; - updateOdeConfig((config) => { - const workspaces = config.workspaces.map((workspace) => { - const channelDetails = workspace.channelDetails.map((channel) => { - if (channel.id !== channelId) return channel; - updated = true; - return updater(channel); - }); - return { ...workspace, channelDetails }; - }); - - if (!updated) { - throw new Error("Channel not found in ~/.config/ode/ode.json"); - } - - return { ...config, workspaces }; - }); -} diff --git a/packages/core/types.ts b/packages/core/types.ts index 6d52d85..ec95fbe 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -5,6 +5,7 @@ import type { OpenCodeSessionInfo, } from "@/agents"; import type { StatusMessageFormat } from "@/config/status-message-format"; +import type { AgentProviderId } from "@/shared/agent-provider"; import type { SessionMessageState } from "@/utils/session-inspector"; export type CoreMessageContext = { @@ -68,7 +69,7 @@ export interface IMAdapter { export interface AgentAdapter { supportsEventStream: boolean; - getProviderForSession(sessionId: string): "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"; + getProviderForSession(sessionId: string): AgentProviderId; getDisplayNameForSession(sessionId: string): string; getOrCreateSession( channelId: string, diff --git a/packages/core/web/local-settings.ts b/packages/core/web/local-settings.ts index 303833c..ffe8aaf 100644 --- a/packages/core/web/local-settings.ts +++ b/packages/core/web/local-settings.ts @@ -4,6 +4,7 @@ import { updateDashboardConfig, type DashboardConfig, } from "@/config"; + export const readLocalSettings = async (): Promise => { return readDashboardConfig(); }; @@ -16,533 +17,6 @@ export const updateLocalSettings = async ( updater: (config: DashboardConfig) => DashboardConfig ): Promise => updateDashboardConfig(updater); -type SlackChannel = { - id: string; - name: string; - is_member?: boolean; -}; - -type SlackTeam = { - id?: string; - name?: string; - domain?: string; -}; - -type DiscordGuild = { - id: string; - name: string; -}; - -type DiscordChannel = { - id: string; - type: number; - name?: string; -}; - -type LarkTenantAccessTokenResponse = { - code?: number; - msg?: string; - tenant_access_token?: string; - expire?: number; -}; - -type LarkTenantInfoResponse = { - code?: number; - msg?: string; - data?: { - tenant?: { - name?: string; - }; - }; -}; - -type LarkChatListResponse = { - code?: number; - msg?: string; - data?: { - items?: Array<{ - chat_id?: string; - name?: string; - }>; - }; -}; - -type ChannelAgentProvider = DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]; - -const KNOWN_AGENT_PROVIDERS = new Set>([ - "opencode", - "claudecode", - "codex", - "kimi", - "kiro", - "kilo", - "qwen", - "goose", - "gemini", -]); - -function normalizeChannelAgentProvider(value: unknown): NonNullable { - if (typeof value !== "string") return "opencode"; - return KNOWN_AGENT_PROVIDERS.has(value as NonNullable) - ? value as NonNullable - : "opencode"; -} - -const slackRequest = async (token: string, path: string, params?: URLSearchParams) => { - const url = new URL(`https://slack.com/api/${path}`); - if (params) { - url.search = params.toString(); - } - const response = await fetch(url.toString(), { - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/x-www-form-urlencoded", - }, - }); - const data = (await response.json()) as T & { ok?: boolean; error?: string }; - if (!data.ok) { - const message = data.error ?? "Slack API error"; - throw new Error(message); - } - return data; -}; - -const discordRequest = async (token: string, path: string) => { - const response = await fetch(`https://discord.com/api/v10${path}`, { - headers: { - authorization: `Bot ${token}`, - "content-type": "application/json", - }, - }); - if (!response.ok) { - let detail = "Discord API error"; - try { - const errorPayload = await response.json() as { message?: string }; - if (errorPayload.message) detail = errorPayload.message; - } catch { - // noop - } - throw new Error(detail); - } - return response.json() as Promise; -}; - -const larkJsonRequest = async ( - path: string, - init?: RequestInit -): Promise => { - const response = await fetch(`https://open.feishu.cn${path}`, { - ...init, - headers: { - "content-type": "application/json; charset=utf-8", - ...(init?.headers ?? {}), - }, - }); - - if (!response.ok) { - throw new Error(`Lark API ${response.status} ${response.statusText}`); - } - - return response.json() as Promise; -}; - -const getLarkTenantAccessToken = async (appId: string, appSecret: string): Promise => { - const result = await larkJsonRequest( - "/open-apis/auth/v3/tenant_access_token/internal", - { - method: "POST", - body: JSON.stringify({ app_id: appId, app_secret: appSecret }), - } - ); - - if ((result.code ?? -1) !== 0 || !result.tenant_access_token) { - throw new Error(result.msg || "Failed to get Lark tenant access token"); - } - - return result.tenant_access_token; -}; - -const larkAuthedRequest = async (token: string, path: string): Promise => { - const result = await larkJsonRequest(path, { - headers: { - authorization: `Bearer ${token}`, - }, - }); - const record = result as { code?: number; msg?: string }; - if ((record.code ?? -1) !== 0) { - throw new Error(record.msg || "Lark API error"); - } - return result; -}; - -const fetchSlackChannels = async (token: string) => { - const channels: SlackChannel[] = []; - let cursor = ""; - do { - const params = new URLSearchParams({ - limit: "200", - types: "public_channel,private_channel", - exclude_archived: "true", - }); - if (cursor) params.set("cursor", cursor); - const data = await slackRequest<{ - channels: SlackChannel[]; - response_metadata?: { next_cursor?: string }; - }>(token, "conversations.list", params); - const joinedChannels = (data.channels ?? []).filter((channel) => channel.is_member === true); - channels.push(...joinedChannels); - cursor = data.response_metadata?.next_cursor ?? ""; - } while (cursor); - return channels; -}; - -const formatSlackDomain = (domain?: string): string => (domain ? `${domain}.slack.com` : ""); - -const fetchSlackWorkspaceSnapshot = async (botToken: string): Promise<{ team: SlackTeam; channels: SlackChannel[] }> => { - const teamInfo = await slackRequest<{ team: SlackTeam }>(botToken, "team.info"); - const channels = await fetchSlackChannels(botToken); - return { team: teamInfo.team ?? {}, channels }; -}; - -const buildDiscoveredChannelDetails = ( - channels: SlackChannel[], - fallbackModel: string -): DashboardConfig["workspaces"][number]["channelDetails"] => - channels.map((channel) => ({ - id: channel.id, - name: channel.name ? `#${channel.name}` : "", - agentProvider: "opencode", - model: fallbackModel, - workingDirectory: "", - baseBranch: "main", - channelSystemMessage: "", - })); - -const buildSyncedChannelDetails = ( - channels: SlackChannel[], - workspace: DashboardConfig["workspaces"][number], - fallbackModel: string -): DashboardConfig["workspaces"][number]["channelDetails"] => - channels.map((channel) => { - const existing = workspace.channelDetails.find((item) => item.id === channel.id); - const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); - - return { - id: channel.id, - name: channel.name ? `#${channel.name}` : "", - agentProvider, - model: existing?.model ?? (agentProvider === "opencode" || agentProvider === "codex" ? fallbackModel : ""), - workingDirectory: existing?.workingDirectory ?? "", - baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", - channelSystemMessage: existing?.channelSystemMessage ?? "", - }; - }); - -export const discoverSlackWorkspace = async ( - slackAppToken: string, - slackBotToken: string -): Promise => { - const appToken = slackAppToken.trim(); - const botToken = slackBotToken.trim(); - if (!appToken) { - throw new Error("Missing Slack app token"); - } - if (!botToken) { - throw new Error("Missing Slack bot token"); - } - - const config = await readLocalSettings(); - const snapshot = await fetchSlackWorkspaceSnapshot(botToken); - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const discoveredWorkspaceId = snapshot.team.id?.trim(); - const workspaceId = discoveredWorkspaceId || `workspace-${config.workspaces.length + 1}`; - const workspaceName = snapshot.team.name?.trim() || `Workspace ${config.workspaces.length + 1}`; - const channelDetails = buildDiscoveredChannelDetails(snapshot.channels, fallbackModel); - - return { - id: workspaceId, - type: "slack", - name: workspaceName, - domain: formatSlackDomain(snapshot.team.domain), - status: "active", - channels: channelDetails.length, - members: 0, - lastSync: new Date().toISOString(), - slackAppToken: appToken, - slackBotToken: botToken, - channelDetails, - }; -}; - -const DISCORD_TEXT_CHANNEL_TYPES = new Set([0, 5, 15]); - -async function fetchDiscordWorkspaceSnapshot(botToken: string): Promise<{ - guild: DiscordGuild; - channels: DiscordChannel[]; -}> { - const guilds = await discordRequest>(botToken, "/users/@me/guilds"); - const guild = guilds[0]; - if (!guild) { - throw new Error("Discord bot is not a member of any guild"); - } - const channels = await discordRequest>(botToken, `/guilds/${guild.id}/channels`); - return { - guild, - channels: channels.filter((channel) => DISCORD_TEXT_CHANNEL_TYPES.has(channel.type)), - }; -} - -function buildDiscordChannelDetails( - channels: DiscordChannel[], - workspace: DashboardConfig["workspaces"][number] | null, - fallbackModel: string -): DashboardConfig["workspaces"][number]["channelDetails"] { - return channels.map((channel) => { - const existing = workspace?.channelDetails.find((item) => item.id === channel.id); - const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); - return { - id: channel.id, - name: channel.name || channel.id, - agentProvider, - model: existing?.model ?? (agentProvider === "opencode" || agentProvider === "codex" ? fallbackModel : ""), - workingDirectory: existing?.workingDirectory ?? "", - baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", - channelSystemMessage: existing?.channelSystemMessage ?? "", - }; - }); -} - -export const discoverDiscordWorkspace = async ( - discordBotToken: string -): Promise => { - const botToken = discordBotToken.trim(); - if (!botToken) { - throw new Error("Missing Discord bot token"); - } - - const config = await readLocalSettings(); - const snapshot = await fetchDiscordWorkspaceSnapshot(botToken); - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const channelDetails = buildDiscordChannelDetails(snapshot.channels, null, fallbackModel); - - return { - id: snapshot.guild.id, - type: "discord", - name: snapshot.guild.name, - domain: "discord.com", - status: "active", - channels: channelDetails.length, - members: 0, - lastSync: new Date().toISOString(), - discordBotToken: botToken, - channelDetails, - }; -}; - -function buildLarkChannelDetails( - chats: Array<{ chat_id?: string; name?: string }>, - workspace: DashboardConfig["workspaces"][number] | null, - fallbackModel: string -): DashboardConfig["workspaces"][number]["channelDetails"] { - return chats - .filter((chat) => typeof chat.chat_id === "string" && chat.chat_id.trim().length > 0) - .map((chat) => { - const chatId = chat.chat_id!.trim(); - const existing = workspace?.channelDetails.find((item) => item.id === chatId); - const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); - return { - id: chatId, - name: chat.name?.trim() || chatId, - agentProvider, - model: existing?.model ?? (agentProvider === "opencode" || agentProvider === "codex" ? fallbackModel : ""), - workingDirectory: existing?.workingDirectory ?? "", - baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", - channelSystemMessage: existing?.channelSystemMessage ?? "", - }; - }); -} - -export const discoverLarkWorkspace = async ( - larkAppKey: string, - larkAppSecret: string -): Promise => { - const appId = larkAppKey.trim(); - const appSecret = larkAppSecret.trim(); - if (!appId) { - throw new Error("Missing Lark app key"); - } - if (!appSecret) { - throw new Error("Missing Lark app secret"); - } - - const config = await readLocalSettings(); - const tenantAccessToken = await getLarkTenantAccessToken(appId, appSecret); - let tenantInfo: LarkTenantInfoResponse = {}; - let chatsResult: LarkChatListResponse = {}; - try { - tenantInfo = await larkAuthedRequest( - tenantAccessToken, - "/open-apis/tenant/v2/tenant/query" - ); - } catch { - tenantInfo = {}; - } - try { - chatsResult = await larkAuthedRequest( - tenantAccessToken, - "/open-apis/im/v1/chats?page_size=100" - ); - } catch { - chatsResult = {}; - } - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const channelDetails = buildLarkChannelDetails(chatsResult.data?.items ?? [], null, fallbackModel); - const workspaceName = - tenantInfo.data?.tenant?.name?.trim() - || `Lark ${appId.slice(0, 8)}`; - - return { - id: `lark-${appId}`, - type: "lark", - name: workspaceName, - domain: "larksuite.com", - status: "active", - channels: channelDetails.length, - members: 0, - lastSync: new Date().toISOString(), - larkAppKey: appId, - larkAppId: appId, - larkAppSecret: appSecret, - channelDetails, - }; -}; - -export const syncLarkWorkspace = async (workspaceId: string): Promise => { - const config = await readLocalSettings(); - const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); - if (workspaceIndex === -1) { - throw new Error("Workspace not found"); - } - - const workspace = config.workspaces[workspaceIndex]!; - if (workspace.type !== "lark") { - throw new Error("Workspace is not Lark type"); - } - - const appId = workspace.larkAppKey?.trim() || workspace.larkAppId?.trim() || ""; - const appSecret = workspace.larkAppSecret?.trim() ?? ""; - if (!appId || !appSecret) { - throw new Error("Missing Lark app credentials"); - } - - const token = await getLarkTenantAccessToken(appId, appSecret); - let tenantInfo: LarkTenantInfoResponse = {}; - try { - tenantInfo = await larkAuthedRequest(token, "/open-apis/tenant/v2/tenant/query"); - } catch { - tenantInfo = {}; - } - let chatsResult: LarkChatListResponse = {}; - try { - chatsResult = await larkAuthedRequest(token, "/open-apis/im/v1/chats?page_size=100"); - } catch { - chatsResult = {}; - } - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const channelDetails = buildLarkChannelDetails(chatsResult.data?.items ?? [], workspace, fallbackModel); - - const updatedWorkspace: DashboardConfig["workspaces"][number] = { - ...workspace, - type: "lark", - name: tenantInfo.data?.tenant?.name?.trim() || workspace.name, - channels: channelDetails.length, - lastSync: new Date().toISOString(), - channelDetails, - }; - - await updateLocalSettings((current) => ({ - ...current, - workspaces: current.workspaces.map((item, index) => - index === workspaceIndex ? updatedWorkspace : item - ), - })); - return updatedWorkspace; -}; - -export const syncDiscordWorkspace = async (workspaceId: string): Promise => { - const config = await readLocalSettings(); - const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); - if (workspaceIndex === -1) { - throw new Error("Workspace not found"); - } - - const workspace = config.workspaces[workspaceIndex]!; - if (workspace.type !== "discord") { - throw new Error("Workspace is not Discord type"); - } - - const botToken = workspace.discordBotToken?.trim() ?? ""; - if (!botToken) { - throw new Error("Missing Discord bot token"); - } - - const snapshot = await fetchDiscordWorkspaceSnapshot(botToken); - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const channelDetails = buildDiscordChannelDetails(snapshot.channels, workspace, fallbackModel); - - const updatedWorkspace: DashboardConfig["workspaces"][number] = { - ...workspace, - type: "discord", - name: snapshot.guild.name || workspace.name, - channels: channelDetails.length, - lastSync: new Date().toISOString(), - channelDetails, - }; - - await updateLocalSettings((current) => ({ - ...current, - workspaces: current.workspaces.map((item, index) => - index === workspaceIndex ? updatedWorkspace : item - ), - })); - return updatedWorkspace; -}; - -export const syncSlackWorkspace = async (workspaceId: string): Promise => { - const config = await readLocalSettings(); - const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); - if (workspaceIndex === -1) { - throw new Error("Workspace not found"); - } - - const workspace = config.workspaces[workspaceIndex]!; - if (workspace.type !== "slack") { - throw new Error("Workspace is not Slack type"); - } - const botToken = workspace.slackBotToken?.trim() ?? ""; - if (!botToken) { - throw new Error("Missing Slack bot token"); - } - - const snapshot = await fetchSlackWorkspaceSnapshot(botToken); - const fallbackModel = config.agents.opencode.models[0] ?? ""; - const channelDetails = buildSyncedChannelDetails(snapshot.channels, workspace, fallbackModel); - - const updatedWorkspace: DashboardConfig["workspaces"][number] = { - ...workspace, - type: "slack", - name: snapshot.team.name ?? workspace.name, - domain: formatSlackDomain(snapshot.team.domain) || workspace.domain, - channels: channelDetails.length, - lastSync: new Date().toISOString(), - channelDetails, - }; - - await updateLocalSettings((current) => ({ - ...current, - workspaces: current.workspaces.map((item, index) => - index === workspaceIndex ? updatedWorkspace : item - ), - })); - return updatedWorkspace; -}; +export { discoverSlackWorkspace, syncSlackWorkspace } from "./local-settings/slack"; +export { discoverDiscordWorkspace, syncDiscordWorkspace } from "./local-settings/discord"; +export { discoverLarkWorkspace, syncLarkWorkspace } from "./local-settings/lark"; diff --git a/packages/core/web/local-settings/discord.ts b/packages/core/web/local-settings/discord.ts new file mode 100644 index 0000000..fcdecbd --- /dev/null +++ b/packages/core/web/local-settings/discord.ts @@ -0,0 +1,140 @@ +import { + readDashboardConfig, + updateDashboardConfig, +} from "@/config"; +import { normalizeChannelAgentProvider, resolveFallbackModel, type WorkspaceConfig } from "./shared"; + +type DiscordGuild = { + id: string; + name: string; +}; + +type DiscordChannel = { + id: string; + type: number; + name?: string; +}; + +const DISCORD_TEXT_CHANNEL_TYPES = new Set([0, 5, 15]); + +const discordRequest = async (token: string, path: string): Promise => { + const response = await fetch(`https://discord.com/api/v10${path}`, { + headers: { + authorization: `Bot ${token}`, + "content-type": "application/json", + }, + }); + if (!response.ok) { + let detail = "Discord API error"; + try { + const errorPayload = await response.json() as { message?: string }; + if (errorPayload.message) detail = errorPayload.message; + } catch { + // noop + } + throw new Error(detail); + } + return response.json() as Promise; +}; + +async function fetchDiscordWorkspaceSnapshot(botToken: string): Promise<{ + guild: DiscordGuild; + channels: DiscordChannel[]; +}> { + const guilds = await discordRequest>(botToken, "/users/@me/guilds"); + const guild = guilds[0]; + if (!guild) { + throw new Error("Discord bot is not a member of any guild"); + } + const channels = await discordRequest>(botToken, `/guilds/${guild.id}/channels`); + return { + guild, + channels: channels.filter((channel) => DISCORD_TEXT_CHANNEL_TYPES.has(channel.type)), + }; +} + +function buildDiscordChannelDetails( + channels: DiscordChannel[], + workspace: WorkspaceConfig | null, + fallbackModel: string +): WorkspaceConfig["channelDetails"] { + return channels.map((channel) => { + const existing = workspace?.channelDetails.find((item) => item.id === channel.id); + const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); + return { + id: channel.id, + name: channel.name || channel.id, + agentProvider, + model: existing?.model ?? resolveFallbackModel(agentProvider, fallbackModel), + workingDirectory: existing?.workingDirectory ?? "", + baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", + channelSystemMessage: existing?.channelSystemMessage ?? "", + }; + }); +} + +export const discoverDiscordWorkspace = async ( + discordBotToken: string +): Promise => { + const botToken = discordBotToken.trim(); + if (!botToken) { + throw new Error("Missing Discord bot token"); + } + + const config = readDashboardConfig(); + const snapshot = await fetchDiscordWorkspaceSnapshot(botToken); + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const channelDetails = buildDiscordChannelDetails(snapshot.channels, null, fallbackModel); + + return { + id: snapshot.guild.id, + type: "discord", + name: snapshot.guild.name, + domain: "discord.com", + status: "active", + channels: channelDetails.length, + members: 0, + lastSync: new Date().toISOString(), + discordBotToken: botToken, + channelDetails, + }; +}; + +export const syncDiscordWorkspace = async (workspaceId: string): Promise => { + const config = readDashboardConfig(); + const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); + if (workspaceIndex === -1) { + throw new Error("Workspace not found"); + } + + const workspace = config.workspaces[workspaceIndex]!; + if (workspace.type !== "discord") { + throw new Error("Workspace is not Discord type"); + } + + const botToken = workspace.discordBotToken?.trim() ?? ""; + if (!botToken) { + throw new Error("Missing Discord bot token"); + } + + const snapshot = await fetchDiscordWorkspaceSnapshot(botToken); + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const channelDetails = buildDiscordChannelDetails(snapshot.channels, workspace, fallbackModel); + + const updatedWorkspace: WorkspaceConfig = { + ...workspace, + type: "discord", + name: snapshot.guild.name || workspace.name, + channels: channelDetails.length, + lastSync: new Date().toISOString(), + channelDetails, + }; + + updateDashboardConfig((current) => ({ + ...current, + workspaces: current.workspaces.map((item, index) => + index === workspaceIndex ? updatedWorkspace : item + ), + })); + return updatedWorkspace; +}; diff --git a/packages/core/web/local-settings/lark.ts b/packages/core/web/local-settings/lark.ts new file mode 100644 index 0000000..95e62ad --- /dev/null +++ b/packages/core/web/local-settings/lark.ts @@ -0,0 +1,210 @@ +import { + readDashboardConfig, + updateDashboardConfig, +} from "@/config"; +import { normalizeChannelAgentProvider, resolveFallbackModel, type WorkspaceConfig } from "./shared"; + +type LarkTenantAccessTokenResponse = { + code?: number; + msg?: string; + tenant_access_token?: string; +}; + +type LarkTenantInfoResponse = { + code?: number; + msg?: string; + data?: { + tenant?: { + name?: string; + }; + }; +}; + +type LarkChatListResponse = { + code?: number; + msg?: string; + data?: { + items?: Array<{ + chat_id?: string; + name?: string; + }>; + }; +}; + +const larkJsonRequest = async ( + path: string, + init?: RequestInit +): Promise => { + const response = await fetch(`https://open.feishu.cn${path}`, { + ...init, + headers: { + "content-type": "application/json; charset=utf-8", + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + throw new Error(`Lark API ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; +}; + +const getLarkTenantAccessToken = async (appId: string, appSecret: string): Promise => { + const result = await larkJsonRequest( + "/open-apis/auth/v3/tenant_access_token/internal", + { + method: "POST", + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + } + ); + + if ((result.code ?? -1) !== 0 || !result.tenant_access_token) { + throw new Error(result.msg || "Failed to get Lark tenant access token"); + } + + return result.tenant_access_token; +}; + +const larkAuthedRequest = async (token: string, path: string): Promise => { + const result = await larkJsonRequest(path, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + const record = result as { code?: number; msg?: string }; + if ((record.code ?? -1) !== 0) { + throw new Error(record.msg || "Lark API error"); + } + return result; +}; + +function buildLarkChannelDetails( + chats: Array<{ chat_id?: string; name?: string }>, + workspace: WorkspaceConfig | null, + fallbackModel: string +): WorkspaceConfig["channelDetails"] { + return chats + .filter((chat) => typeof chat.chat_id === "string" && chat.chat_id.trim().length > 0) + .map((chat) => { + const chatId = chat.chat_id!.trim(); + const existing = workspace?.channelDetails.find((item) => item.id === chatId); + const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); + return { + id: chatId, + name: chat.name?.trim() || chatId, + agentProvider, + model: existing?.model ?? resolveFallbackModel(agentProvider, fallbackModel), + workingDirectory: existing?.workingDirectory ?? "", + baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", + channelSystemMessage: existing?.channelSystemMessage ?? "", + }; + }); +} + +export const discoverLarkWorkspace = async ( + larkAppKey: string, + larkAppSecret: string +): Promise => { + const appId = larkAppKey.trim(); + const appSecret = larkAppSecret.trim(); + if (!appId) { + throw new Error("Missing Lark app key"); + } + if (!appSecret) { + throw new Error("Missing Lark app secret"); + } + + const config = readDashboardConfig(); + const tenantAccessToken = await getLarkTenantAccessToken(appId, appSecret); + let tenantInfo: LarkTenantInfoResponse = {}; + let chatsResult: LarkChatListResponse = {}; + try { + tenantInfo = await larkAuthedRequest( + tenantAccessToken, + "/open-apis/tenant/v2/tenant/query" + ); + } catch { + tenantInfo = {}; + } + try { + chatsResult = await larkAuthedRequest( + tenantAccessToken, + "/open-apis/im/v1/chats?page_size=100" + ); + } catch { + chatsResult = {}; + } + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const channelDetails = buildLarkChannelDetails(chatsResult.data?.items ?? [], null, fallbackModel); + const workspaceName = + tenantInfo.data?.tenant?.name?.trim() + || `Lark ${appId.slice(0, 8)}`; + + return { + id: `lark-${appId}`, + type: "lark", + name: workspaceName, + domain: "larksuite.com", + status: "active", + channels: channelDetails.length, + members: 0, + lastSync: new Date().toISOString(), + larkAppKey: appId, + larkAppId: appId, + larkAppSecret: appSecret, + channelDetails, + }; +}; + +export const syncLarkWorkspace = async (workspaceId: string): Promise => { + const config = readDashboardConfig(); + const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); + if (workspaceIndex === -1) { + throw new Error("Workspace not found"); + } + + const workspace = config.workspaces[workspaceIndex]!; + if (workspace.type !== "lark") { + throw new Error("Workspace is not Lark type"); + } + + const appId = workspace.larkAppKey?.trim() || workspace.larkAppId?.trim() || ""; + const appSecret = workspace.larkAppSecret?.trim() ?? ""; + if (!appId || !appSecret) { + throw new Error("Missing Lark app credentials"); + } + + const token = await getLarkTenantAccessToken(appId, appSecret); + let tenantInfo: LarkTenantInfoResponse = {}; + try { + tenantInfo = await larkAuthedRequest(token, "/open-apis/tenant/v2/tenant/query"); + } catch { + tenantInfo = {}; + } + let chatsResult: LarkChatListResponse = {}; + try { + chatsResult = await larkAuthedRequest(token, "/open-apis/im/v1/chats?page_size=100"); + } catch { + chatsResult = {}; + } + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const channelDetails = buildLarkChannelDetails(chatsResult.data?.items ?? [], workspace, fallbackModel); + + const updatedWorkspace: WorkspaceConfig = { + ...workspace, + type: "lark", + name: tenantInfo.data?.tenant?.name?.trim() || workspace.name, + channels: channelDetails.length, + lastSync: new Date().toISOString(), + channelDetails, + }; + + updateDashboardConfig((current) => ({ + ...current, + workspaces: current.workspaces.map((item, index) => + index === workspaceIndex ? updatedWorkspace : item + ), + })); + return updatedWorkspace; +}; diff --git a/packages/core/web/local-settings/shared.ts b/packages/core/web/local-settings/shared.ts new file mode 100644 index 0000000..005376d --- /dev/null +++ b/packages/core/web/local-settings/shared.ts @@ -0,0 +1,20 @@ +import type { DashboardConfig } from "@/config"; +import { normalizeAgentProviderId } from "@/shared/agent-provider"; + +export type WorkspaceConfig = DashboardConfig["workspaces"][number]; +export type ChannelDetail = WorkspaceConfig["channelDetails"][number]; + +type ChannelAgentProvider = ChannelDetail["agentProvider"]; + +export function normalizeChannelAgentProvider(value: unknown): NonNullable { + return normalizeAgentProviderId(value); +} + +export function resolveFallbackModel( + agentProvider: NonNullable, + fallbackModel: string +): string { + return agentProvider === "opencode" || agentProvider === "codex" + ? fallbackModel + : ""; +} diff --git a/packages/core/web/local-settings/slack.ts b/packages/core/web/local-settings/slack.ts new file mode 100644 index 0000000..46421bb --- /dev/null +++ b/packages/core/web/local-settings/slack.ts @@ -0,0 +1,165 @@ +import { + readDashboardConfig, + updateDashboardConfig, + type DashboardConfig, +} from "@/config"; +import { normalizeChannelAgentProvider, resolveFallbackModel, type WorkspaceConfig } from "./shared"; + +type SlackChannel = { + id: string; + name: string; + is_member?: boolean; +}; + +type SlackTeam = { + id?: string; + name?: string; + domain?: string; +}; + +const slackRequest = async (token: string, path: string, params?: URLSearchParams) => { + const url = new URL(`https://slack.com/api/${path}`); + if (params) { + url.search = params.toString(); + } + const response = await fetch(url.toString(), { + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/x-www-form-urlencoded", + }, + }); + const data = (await response.json()) as T & { ok?: boolean; error?: string }; + if (!data.ok) { + throw new Error(data.error ?? "Slack API error"); + } + return data; +}; + +const fetchSlackChannels = async (token: string): Promise => { + const channels: SlackChannel[] = []; + let cursor = ""; + do { + const params = new URLSearchParams({ + limit: "200", + types: "public_channel,private_channel", + exclude_archived: "true", + }); + if (cursor) params.set("cursor", cursor); + const data = await slackRequest<{ + channels: SlackChannel[]; + response_metadata?: { next_cursor?: string }; + }>(token, "conversations.list", params); + const joinedChannels = (data.channels ?? []).filter((channel) => channel.is_member === true); + channels.push(...joinedChannels); + cursor = data.response_metadata?.next_cursor ?? ""; + } while (cursor); + return channels; +}; + +const formatSlackDomain = (domain?: string): string => (domain ? `${domain}.slack.com` : ""); + +const fetchSlackWorkspaceSnapshot = async (botToken: string): Promise<{ team: SlackTeam; channels: SlackChannel[] }> => { + const teamInfo = await slackRequest<{ team: SlackTeam }>(botToken, "team.info"); + const channels = await fetchSlackChannels(botToken); + return { team: teamInfo.team ?? {}, channels }; +}; + +const buildDiscoveredChannelDetails = ( + channels: SlackChannel[], + fallbackModel: string +): WorkspaceConfig["channelDetails"] => + channels.map((channel) => ({ + id: channel.id, + name: channel.name ? `#${channel.name}` : "", + agentProvider: "opencode", + model: fallbackModel, + workingDirectory: "", + baseBranch: "main", + channelSystemMessage: "", + })); + +const buildSyncedChannelDetails = ( + channels: SlackChannel[], + workspace: WorkspaceConfig, + fallbackModel: string +): WorkspaceConfig["channelDetails"] => + channels.map((channel) => { + const existing = workspace.channelDetails.find((item) => item.id === channel.id); + const agentProvider = normalizeChannelAgentProvider(existing?.agentProvider); + return { + id: channel.id, + name: channel.name ? `#${channel.name}` : "", + agentProvider, + model: existing?.model ?? resolveFallbackModel(agentProvider, fallbackModel), + workingDirectory: existing?.workingDirectory ?? "", + baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main", + channelSystemMessage: existing?.channelSystemMessage ?? "", + }; + }); + +export const discoverSlackWorkspace = async ( + slackAppToken: string, + slackBotToken: string +): Promise => { + const appToken = slackAppToken.trim(); + const botToken = slackBotToken.trim(); + if (!appToken) throw new Error("Missing Slack app token"); + if (!botToken) throw new Error("Missing Slack bot token"); + + const config = readDashboardConfig(); + const snapshot = await fetchSlackWorkspaceSnapshot(botToken); + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const discoveredWorkspaceId = snapshot.team.id?.trim(); + const workspaceId = discoveredWorkspaceId || `workspace-${config.workspaces.length + 1}`; + const workspaceName = snapshot.team.name?.trim() || `Workspace ${config.workspaces.length + 1}`; + const channelDetails = buildDiscoveredChannelDetails(snapshot.channels, fallbackModel); + + return { + id: workspaceId, + type: "slack", + name: workspaceName, + domain: formatSlackDomain(snapshot.team.domain), + status: "active", + channels: channelDetails.length, + members: 0, + lastSync: new Date().toISOString(), + slackAppToken: appToken, + slackBotToken: botToken, + channelDetails, + }; +}; + +export const syncSlackWorkspace = async (workspaceId: string): Promise => { + const config = readDashboardConfig(); + const workspaceIndex = config.workspaces.findIndex((item) => item.id === workspaceId); + if (workspaceIndex === -1) throw new Error("Workspace not found"); + + const workspace = config.workspaces[workspaceIndex]!; + if (workspace.type !== "slack") throw new Error("Workspace is not Slack type"); + + const botToken = workspace.slackBotToken?.trim() ?? ""; + if (!botToken) throw new Error("Missing Slack bot token"); + + const snapshot = await fetchSlackWorkspaceSnapshot(botToken); + const fallbackModel = config.agents.opencode.models[0] ?? ""; + const channelDetails = buildSyncedChannelDetails(snapshot.channels, workspace, fallbackModel); + + const updatedWorkspace: WorkspaceConfig = { + ...workspace, + type: "slack", + name: snapshot.team.name ?? workspace.name, + domain: formatSlackDomain(snapshot.team.domain) || workspace.domain, + channels: channelDetails.length, + lastSync: new Date().toISOString(), + channelDetails, + }; + + updateDashboardConfig((current) => ({ + ...current, + workspaces: current.workspaces.map((item, index) => + index === workspaceIndex ? updatedWorkspace : item + ), + })); + + return updatedWorkspace; +}; diff --git a/packages/ims/discord/settings.ts b/packages/ims/discord/settings.ts index ecfce72..62f54b7 100644 --- a/packages/ims/discord/settings.ts +++ b/packages/ims/discord/settings.ts @@ -8,6 +8,17 @@ import { TextInputBuilder, TextInputStyle, } from "discord.js"; +import { + findMatchingModel, + getProviderModelList, + resolveStoredModelForProvider, + type ProviderModelLists, +} from "@/shared/channel-settings"; +import { + AGENT_PROVIDERS, + isAgentProviderId, + type AgentProviderId, +} from "@/shared/agent-provider"; import { getChannelSystemMessage, getChannelAgentProvider, @@ -42,9 +53,9 @@ const STATUS_FREQUENCY_OPTIONS: StatusMessageFrequencyValue[] = STATUS_MESSAGE_FREQUENCY_OPTIONS.map((option) => option.value); const GIT_STRATEGY_OPTIONS = ["worktree", "default"] as const; const AUTO_UPDATE_OPTIONS = ["on", "off"] as const; -const PROVIDERS = ["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"] as const; +const PROVIDERS = AGENT_PROVIDERS; -const channelSettingsDrafts = new Map(); +const channelSettingsDrafts = new Map(); const generalSettingsDrafts = new Map normalizeModel(model) === target); +function getProviderModels(provider: typeof PROVIDERS[number]): string[] { + return getProviderModelList(provider, getProviderModelLists()); } -function getProviderModels(provider: typeof PROVIDERS[number]): string[] { - if (provider === "opencode") return getOpenCodeModels(); - if (provider === "codex") return getCodexModels(); - if (provider === "kilo") return getKiloModels(); - return []; +function getProviderModelLists(): ProviderModelLists { + return { + opencode: getOpenCodeModels(), + codex: getCodexModels(), + kilo: getKiloModels(), + }; } function draftKey(userId: string, channelId: string): string { return `${userId}:${channelId}`; } -function getInitialChannelDraft(channelId: string): { provider: typeof PROVIDERS[number]; model: string } { +function getInitialChannelDraft(channelId: string): { provider: AgentProviderId; model: string } { const provider = getChannelAgentProvider(channelId); return { provider, @@ -156,7 +162,7 @@ function getInitialChannelDraft(channelId: string): { provider: typeof PROVIDERS }; } -function getDraftOrInitial(userId: string, channelId: string): { provider: typeof PROVIDERS[number]; model: string } { +function getDraftOrInitial(userId: string, channelId: string): { provider: AgentProviderId; model: string } { return channelSettingsDrafts.get(draftKey(userId, channelId)) ?? getInitialChannelDraft(channelId); } @@ -186,7 +192,7 @@ function buildChannelSettingsPickerPayload(params: { const models = getProviderModels(draft.provider); if (models.length > 0) { - const selectedModel = draft.model && hasModel(models, draft.model) ? draft.model : models[0]!; + const selectedModel = findMatchingModel(models, draft.model) ?? models[0]!; const modelOptions = models.slice(0, 25).map((model) => ({ label: model.slice(0, 100), value: model, @@ -617,7 +623,7 @@ async function handleChannelSettingsComponentInteraction(interaction: any): Prom const models = getProviderModels(parsed); const nextDraft = { provider: parsed, - model: models.length > 0 ? (hasModel(models, draft.model) ? draft.model : models[0]!) : "", + model: models.length > 0 ? (findMatchingModel(models, draft.model) ?? models[0]!) : "", }; channelSettingsDrafts.set(key, nextDraft); await interaction.update(buildChannelSettingsPickerPayload({ channelId, userId })); @@ -638,24 +644,17 @@ async function handleChannelSettingsComponentInteraction(interaction: any): Prom if (action === "save") { const models = getProviderModels(draft.provider); - if (models.length > 0 && draft.model && !hasModel(models, draft.model)) { + if (models.length > 0 && draft.model && !findMatchingModel(models, draft.model)) { await interaction.reply({ content: "Selected model is no longer available.", flags: MessageFlags.Ephemeral }); return true; } setChannelAgentProvider(channelId, draft.provider); - if ( - draft.provider === "claudecode" || - draft.provider === "kimi" || - draft.provider === "kiro" || - draft.provider === "qwen" || - draft.provider === "goose" || - draft.provider === "gemini" - ) { - setChannelModel(channelId, ""); - } else { - setChannelModel(channelId, draft.model); - } + setChannelModel(channelId, resolveStoredModelForProvider({ + provider: draft.provider, + selectedModel: draft.model, + lists: getProviderModelLists(), + })); channelSettingsDrafts.delete(key); await interaction.reply({ content: "Channel provider/model updated.", flags: MessageFlags.Ephemeral }); return true; diff --git a/packages/ims/slack/commands.ts b/packages/ims/slack/commands.ts index 69c3605..1569e61 100644 --- a/packages/ims/slack/commands.ts +++ b/packages/ims/slack/commands.ts @@ -1,4 +1,19 @@ import { getApps } from "./client"; +import { + findMatchingModel, + getProviderModelList, + MODEL_DEFAULT_SENTINEL, + MODEL_NONE_SENTINEL, + resolveStoredModelForProvider, + validateProviderModelSelection, + type ProviderModelLists, +} from "@/shared/channel-settings"; +import { + AGENT_PROVIDERS, + normalizeAgentProviderId, + providerSupportsModelSelection, + type AgentProviderId, +} from "@/shared/agent-provider"; import { getChannelAgentProvider, getChannelModel, @@ -58,7 +73,7 @@ const GENERAL_GIT_STRATEGY_ACTION = "general_git_strategy_select"; const GENERAL_AUTO_UPDATE_BLOCK = "general_auto_update"; const GENERAL_AUTO_UPDATE_ACTION = "general_auto_update_select"; -type AgentProvider = "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"; +type AgentProvider = AgentProviderId; type StatusMessageFormat = "aggressive" | "medium" | "minimum"; type GitStrategy = "default" | "worktree"; @@ -91,8 +106,6 @@ type SlackActionBody = { }; }; -const AGENT_PROVIDERS: AgentProvider[] = ["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]; - const AGENT_PROVIDER_LABELS: Record = { opencode: "OpenCode", claudecode: "Claude Code", @@ -127,18 +140,7 @@ const AUTO_UPDATE_OPTIONS: Array<{ label: string; value: "on" | "off" }> = [ ]; function parseAgentProvider(value: unknown): AgentProvider { - if (typeof value !== "string") return "opencode"; - return AGENT_PROVIDERS.includes(value as AgentProvider) ? value as AgentProvider : "opencode"; -} - -function normalizeModel(value: string): string { - return value.trim().toLowerCase(); -} - -function findMatchingModel(models: string[], value: string | null | undefined): string | null { - if (!value) return null; - const target = normalizeModel(value); - return models.find((model) => normalizeModel(model) === target) ?? null; + return normalizeAgentProviderId(value); } function getSelectableProviders(): AgentProvider[] { @@ -146,13 +148,21 @@ function getSelectableProviders(): AgentProvider[] { (provider): provider is AgentProvider => AGENT_PROVIDERS.includes(provider as AgentProvider) ); if (enabled.length > 0) return enabled; - return AGENT_PROVIDERS; + return Array.from(AGENT_PROVIDERS); } -function toSelectableProvider(provider: "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"): AgentProvider { +function toSelectableProvider(provider: AgentProviderId): AgentProvider { return parseAgentProvider(provider); } +function getProviderModelLists(): ProviderModelLists { + return { + opencode: getOpenCodeModels(), + codex: getCodexModels(), + kilo: getKiloModels(), + }; +} + function getActionChannelId(body: SlackActionBody): string | undefined { return body.actions?.[0]?.value ?? body.channel?.id; } @@ -213,23 +223,23 @@ function buildSettingsModal(params: { text: { type: "plain_text" as const, text: AGENT_PROVIDER_LABELS[provider] }, value: provider, })); - const providerModels = selectedProvider === "opencode" - ? opencodeModels - : selectedProvider === "codex" - ? codexModels - : selectedProvider === "kilo" - ? kiloModels - : null; + const providerModels = providerSupportsModelSelection(selectedProvider) + ? getProviderModelList(selectedProvider, { + opencode: opencodeModels, + codex: codexModels, + kilo: kiloModels, + }) + : null; const modelOptions = providerModels && selectedProvider === "opencode" ? (opencodeModels.length > 0 ? opencodeModels.map((model) => ({ text: { type: "plain_text" as const, text: model }, value: model, })) - : [{ text: { type: "plain_text" as const, text: "No models configured" }, value: "__none__" }]) + : [{ text: { type: "plain_text" as const, text: "No models configured" }, value: MODEL_NONE_SENTINEL }]) : providerModels && selectedProvider === "codex" ? [ - { text: { type: "plain_text" as const, text: "Use default (gpt-5.3-codex)" }, value: "__default__" }, + { text: { type: "plain_text" as const, text: "Use default (gpt-5.3-codex)" }, value: MODEL_DEFAULT_SENTINEL }, ...codexModels.map((model) => ({ text: { type: "plain_text" as const, text: model }, value: model, @@ -241,7 +251,7 @@ function buildSettingsModal(params: { text: { type: "plain_text" as const, text: model }, value: model, })) - : [{ text: { type: "plain_text" as const, text: "No models configured" }, value: "__none__" }]) + : [{ text: { type: "plain_text" as const, text: "No models configured" }, value: MODEL_NONE_SENTINEL }]) : []; const availableModels = selectedProvider === "codex" @@ -251,10 +261,10 @@ function buildSettingsModal(params: { const initialModel = matchedSelectedModel ? matchedSelectedModel : (selectedProvider === "codex" - ? "__default__" + ? MODEL_DEFAULT_SENTINEL : selectedProvider === "kilo" - ? (kiloModels[0] ?? "__none__") - : (opencodeModels[0] ?? "__none__")); + ? (kiloModels[0] ?? MODEL_NONE_SENTINEL) + : (opencodeModels[0] ?? MODEL_NONE_SENTINEL)); const introText = selectedProvider === "opencode" ? "Configure agent, model (OpenCode), working directory, and base branch for this channel." : selectedProvider === "codex" @@ -683,25 +693,34 @@ export function setupInteractiveHandlers(): void { } if (selectedProvider === "opencode") { - if (!selectedModel || selectedModel === "__none__") { + if (!selectedModel || selectedModel === MODEL_NONE_SENTINEL) { errors[MODEL_BLOCK] = "Select a model."; } - const models = getOpenCodeModels(); - if (selectedModel && !findMatchingModel(models, selectedModel)) { + if (!validateProviderModelSelection({ + provider: "opencode", + selectedModel, + lists: getProviderModelLists(), + })) { errors[MODEL_BLOCK] = "Model not available in ~/.config/ode/ode.json agents.opencode.models."; } } else if (selectedProvider === "codex") { - const models = getCodexModels(); - if (selectedModel && selectedModel !== "__default__" && !findMatchingModel(models, selectedModel)) { + if (!validateProviderModelSelection({ + provider: "codex", + selectedModel, + lists: getProviderModelLists(), + })) { errors[MODEL_BLOCK] = "Model not available in local Codex model list."; } } else if (selectedProvider === "kilo") { - if (!selectedModel || selectedModel === "__none__") { + if (!selectedModel || selectedModel === MODEL_NONE_SENTINEL) { errors[MODEL_BLOCK] = "Select a model."; } - const models = getKiloModels(); - if (selectedModel && !findMatchingModel(models, selectedModel)) { + if (!validateProviderModelSelection({ + provider: "kilo", + selectedModel, + lists: getProviderModelLists(), + })) { errors[MODEL_BLOCK] = "Model not available in local Kilo model list."; } } @@ -715,25 +734,11 @@ export function setupInteractiveHandlers(): void { try { setChannelAgentProvider(channelId, selectedProvider); - if (selectedProvider === "opencode" && selectedModel && selectedModel !== "__none__") { - const normalizedSelectedModel = findMatchingModel(getOpenCodeModels(), selectedModel) ?? selectedModel; - setChannelModel(channelId, normalizedSelectedModel); - } - if (selectedProvider === "codex") { - if (selectedModel && selectedModel !== "__default__") { - const normalizedSelectedModel = findMatchingModel(getCodexModels(), selectedModel) ?? selectedModel; - setChannelModel(channelId, normalizedSelectedModel); - } else { - setChannelModel(channelId, ""); - } - } - if (selectedProvider === "kilo" && selectedModel && selectedModel !== "__none__") { - const normalizedSelectedModel = findMatchingModel(getKiloModels(), selectedModel) ?? selectedModel; - setChannelModel(channelId, normalizedSelectedModel); - } - if (selectedProvider === "claudecode" || selectedProvider === "kimi" || selectedProvider === "kiro" || selectedProvider === "qwen" || selectedProvider === "goose" || selectedProvider === "gemini") { - setChannelModel(channelId, ""); - } + setChannelModel(channelId, resolveStoredModelForProvider({ + provider: selectedProvider, + selectedModel, + lists: getProviderModelLists(), + })); const workingDirValue = workingDirectory.trim(); setChannelWorkingDirectory(channelId, workingDirValue.length > 0 ? workingDirValue : null); diff --git a/packages/ims/slack/message-router.ts b/packages/ims/slack/message-router.ts index dd33fc0..52966d1 100644 --- a/packages/ims/slack/message-router.ts +++ b/packages/ims/slack/message-router.ts @@ -4,6 +4,7 @@ import { evaluateIncomingMessage, formatIncomingDropMessage } from "@/ims/shared import { executeIncomingFlow } from "@/ims/shared/incoming-executor"; import { buildIncomingContext } from "@/ims/shared/incoming-normalizer"; import { parseIncomingCommand } from "@/ims/shared/command-router"; +import type { AgentProviderId } from "@/shared/agent-provider"; import { toCoreMessageContext, type UnifiedMessageContext, @@ -30,7 +31,7 @@ type RouterDeps = { markThreadActive: (channelId: string, threadId: string) => void; postGeneralSettingsLauncher: (channelId: string, userId: string, client: any) => Promise; describeSettingsIssues: (channelId: string) => string[]; - getChannelAgentProvider: (channelId: string) => "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"; + getChannelAgentProvider: (channelId: string) => AgentProviderId; handleStopCommand: (channelId: string, threadId: string) => Promise; handleIncomingMessage: (context: { channelId: string; diff --git a/packages/shared/agent-provider.ts b/packages/shared/agent-provider.ts new file mode 100644 index 0000000..71db176 --- /dev/null +++ b/packages/shared/agent-provider.ts @@ -0,0 +1,30 @@ +export const AGENT_PROVIDERS = [ + "opencode", + "claudecode", + "codex", + "kimi", + "kiro", + "kilo", + "qwen", + "goose", + "gemini", +] as const; + +export type AgentProviderId = (typeof AGENT_PROVIDERS)[number]; + +const AGENT_PROVIDER_SET = new Set(AGENT_PROVIDERS); + +export function isAgentProviderId(value: unknown): value is AgentProviderId { + return typeof value === "string" && AGENT_PROVIDER_SET.has(value); +} + +export function normalizeAgentProviderId( + value: unknown, + fallback: AgentProviderId = "opencode" +): AgentProviderId { + return isAgentProviderId(value) ? value : fallback; +} + +export function providerSupportsModelSelection(provider: AgentProviderId): boolean { + return provider === "opencode" || provider === "codex" || provider === "kilo"; +} diff --git a/packages/shared/channel-settings.ts b/packages/shared/channel-settings.ts new file mode 100644 index 0000000..09b6cad --- /dev/null +++ b/packages/shared/channel-settings.ts @@ -0,0 +1,73 @@ +import { + providerSupportsModelSelection, + type AgentProviderId, +} from "@/shared/agent-provider"; + +export const MODEL_NONE_SENTINEL = "__none__"; +export const MODEL_DEFAULT_SENTINEL = "__default__"; + +export type ProviderModelLists = { + opencode: string[]; + codex: string[]; + kilo: string[]; +}; + +function normalizeModel(value: string): string { + return value.trim().toLowerCase(); +} + +export function findMatchingModel( + models: string[], + value: string | null | undefined +): string | null { + if (!value) return null; + const target = normalizeModel(value); + return models.find((model) => normalizeModel(model) === target) ?? null; +} + +export function getProviderModelList( + provider: AgentProviderId, + lists: ProviderModelLists +): string[] { + if (provider === "opencode") return lists.opencode; + if (provider === "codex") return lists.codex; + if (provider === "kilo") return lists.kilo; + return []; +} + +export function validateProviderModelSelection(params: { + provider: AgentProviderId; + selectedModel: string | null | undefined; + lists: ProviderModelLists; +}): boolean { + const { provider, selectedModel, lists } = params; + if (!providerSupportsModelSelection(provider)) return true; + + if (provider === "codex") { + if (!selectedModel || selectedModel === MODEL_DEFAULT_SENTINEL) return true; + return findMatchingModel(lists.codex, selectedModel) !== null; + } + + if (!selectedModel || selectedModel === MODEL_NONE_SENTINEL) { + return false; + } + + return findMatchingModel(getProviderModelList(provider, lists), selectedModel) !== null; +} + +export function resolveStoredModelForProvider(params: { + provider: AgentProviderId; + selectedModel: string | null | undefined; + lists: ProviderModelLists; +}): string { + const { provider, selectedModel, lists } = params; + if (!providerSupportsModelSelection(provider)) return ""; + + if (provider === "codex") { + if (!selectedModel || selectedModel === MODEL_DEFAULT_SENTINEL) return ""; + return findMatchingModel(lists.codex, selectedModel) ?? selectedModel; + } + + if (!selectedModel || selectedModel === MODEL_NONE_SENTINEL) return ""; + return findMatchingModel(getProviderModelList(provider, lists), selectedModel) ?? selectedModel; +} diff --git a/packages/utils/session-inspector.ts b/packages/utils/session-inspector.ts index a8e3307..1f85ef9 100644 --- a/packages/utils/session-inspector.ts +++ b/packages/utils/session-inspector.ts @@ -81,42 +81,88 @@ function applySessionUpdatedEvent(state: SessionMessageState, eventProps: Record state.sessionTitle = sessionTitle; } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function asNumber(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function extractMessageInfo(eventProps: Record): Record | undefined { + const direct = asRecord(eventProps.info); + if (direct) return direct; + + const message = asRecord(eventProps.message); + if (!message) return undefined; + + const nestedInfo = asRecord(message.info); + if (nestedInfo) return nestedInfo; + return message; +} + function applyMessageUpdatedEvent(state: SessionMessageState, eventProps: Record): void { - const info = eventProps.info as - | { - modelID?: unknown; - agent?: unknown; - tokens?: { - total?: unknown; - input?: unknown; - output?: unknown; - reasoning?: unknown; - cache?: { read?: unknown; write?: unknown }; - }; - cost?: unknown; - } - | undefined; - if (typeof info?.modelID === "string" && info.modelID.trim()) { - state.model = info.modelID; + const info = extractMessageInfo(eventProps); + if (!info) return; + + const modelCandidate = + (typeof info.modelID === "string" ? info.modelID : undefined) + ?? (typeof info.modelId === "string" ? info.modelId : undefined) + ?? (typeof info.model === "string" ? info.model : undefined); + if (typeof modelCandidate === "string" && modelCandidate.trim()) { + state.model = modelCandidate; } - if (typeof info?.agent === "string" && info.agent.trim()) { - state.agent = info.agent; + const agentCandidate = + (typeof info.agent === "string" ? info.agent : undefined) + ?? (typeof info.agentName === "string" ? info.agentName : undefined) + ?? (typeof info.assistant === "string" ? info.assistant : undefined); + if (typeof agentCandidate === "string" && agentCandidate.trim()) { + state.agent = agentCandidate; } - const tokens = info?.tokens; + const tokenContainer = + asRecord(info.tokens) + ?? asRecord(info.tokenUsage) + ?? asRecord(info.usage); + const cacheContainer = + asRecord(tokenContainer?.cache) + ?? asRecord(tokenContainer?.cache_tokens) + ?? asRecord(tokenContainer?.cacheTokens); + + const tokens = tokenContainer; if (tokens && typeof tokens === "object") { - const input = Number(tokens.input ?? 0) || 0; - const output = Number(tokens.output ?? 0) || 0; - const reasoning = Number(tokens.reasoning ?? 0) || 0; - const cacheRead = Number(tokens.cache?.read ?? 0) || 0; - const cacheWrite = Number(tokens.cache?.write ?? 0) || 0; - const reportedTotal = Number(tokens.total); - const total = Number.isFinite(reportedTotal) + const hasTokenSignal = [ + tokens.input, + tokens.input_tokens, + tokens.output, + tokens.output_tokens, + tokens.reasoning, + tokens.reasoning_tokens, + tokens.total, + tokens.total_tokens, + cacheContainer?.read, + cacheContainer?.write, + cacheContainer?.input_tokens, + cacheContainer?.output_tokens, + ].some((value) => value !== undefined && value !== null); + if (!hasTokenSignal) { + return; + } + + const input = asNumber(tokens.input ?? tokens.input_tokens) ?? 0; + const output = asNumber(tokens.output ?? tokens.output_tokens) ?? 0; + const reasoning = asNumber(tokens.reasoning ?? tokens.reasoning_tokens) ?? 0; + const cacheRead = asNumber(cacheContainer?.read ?? cacheContainer?.input_tokens) ?? 0; + const cacheWrite = asNumber(cacheContainer?.write ?? cacheContainer?.output_tokens) ?? 0; + const reportedTotal = asNumber(tokens.total ?? tokens.total_tokens); + const total = typeof reportedTotal === "number" && Number.isFinite(reportedTotal) ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite; - const parsedCost = Number(info?.cost); - const cost = Number.isFinite(parsedCost) ? parsedCost : undefined; + const cost = asNumber(info.cost ?? tokens.cost); state.tokenUsage = { input, output, diff --git a/packages/utils/status.ts b/packages/utils/status.ts index f182489..f50fadc 100644 --- a/packages/utils/status.ts +++ b/packages/utils/status.ts @@ -2,6 +2,7 @@ import { TOOL_DISPLAY_CONFIG, type StatusMessageFormat, } from "@/config/web"; +import type { AgentProviderId } from "@/shared/agent-provider"; import type { SessionMessageState } from "./session-inspector"; export type StatusRequest = { @@ -13,7 +14,7 @@ export type StatusRequest = { statusFrozen?: boolean; }; -export type AgentStatusProvider = "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini"; +export type AgentStatusProvider = AgentProviderId; const PROVIDER_FALLBACK_TITLES: Record = { opencode: "Opencode is running...",