From 0459769746fdc79b98bb6ebe346ace44f480d84d Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 13:59:58 +0800 Subject: [PATCH 01/30] feat(desktop): add auto-update functionality Implement one-click desktop auto-update with version checking, download progress, and automatic installation. Includes toast notification UI in bottom-right corner showing update status (checking, available, downloading, ready, or error). Co-Authored-By: Claude Haiku 4.5 --- apps/desktop/dev-app-update.yml | 3 + apps/desktop/electron-builder.json5 | 7 +- apps/desktop/package.json | 1 + apps/desktop/src/main/index.ts | 28 +++ apps/desktop/src/main/updater/index.ts | 121 +++++++++++ apps/desktop/src/preload/index.ts | 20 ++ .../src/components/update-notification.tsx | 144 +++++++++++++ .../desktop/src/renderer/src/pages/layout.tsx | 2 + pnpm-lock.yaml | 197 +++++++++--------- 9 files changed, 428 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/dev-app-update.yml create mode 100644 apps/desktop/src/main/updater/index.ts create mode 100644 apps/desktop/src/renderer/src/components/update-notification.tsx diff --git a/apps/desktop/dev-app-update.yml b/apps/desktop/dev-app-update.yml new file mode 100644 index 00000000..95ca79f9 --- /dev/null +++ b/apps/desktop/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: github +owner: multica-ai +repo: multica diff --git a/apps/desktop/electron-builder.json5 b/apps/desktop/electron-builder.json5 index b30a5ab7..0a7de5ff 100644 --- a/apps/desktop/electron-builder.json5 +++ b/apps/desktop/electron-builder.json5 @@ -63,5 +63,10 @@ "category": "Utility", "artifactName": "${productName}-Linux-${version}.${ext}" }, - "npmRebuild": false + "npmRebuild": false, + "publish": { + "provider": "github", + "owner": "multica-ai", + "repo": "multica" + } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f9411f11..446fe137 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", + "electron-updater": "^6.7.3", "qrcode.react": "^4.2.0", "react": "catalog:", "react-dom": "catalog:", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ad958405..509452fd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { app, BrowserWindow, shell, ipcMain } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js' +import { createUpdater, AutoUpdater } from './updater/index.js' // CJS output will have __dirname natively, but TypeScript source needs this for type checking const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -67,6 +68,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, const forceOnboarding = process.argv.includes('--force-onboarding') let win: BrowserWindow | null +let updater: AutoUpdater function createWindow() { win = new BrowserWindow({ @@ -128,4 +130,30 @@ app.whenReady().then(async () => { if (win) { setupDeviceConfirmation(win) } + + // Initialize auto-updater + const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true' + updater = createUpdater(forceDevUpdate) + updater.setMainWindow(() => win) + + // Auto-check for updates in production (or when forced in dev) + const isDev = !!VITE_DEV_SERVER_URL + if (!isDev || forceDevUpdate) { + win?.once('ready-to-show', () => { + updater.checkForUpdates() + }) + } + + // Update IPC handlers + ipcMain.handle('update:check', async () => { + await updater.checkForUpdates() + }) + + ipcMain.handle('update:download', async () => { + await updater.downloadUpdate() + }) + + ipcMain.handle('update:install', () => { + updater.quitAndInstall() + }) }) diff --git a/apps/desktop/src/main/updater/index.ts b/apps/desktop/src/main/updater/index.ts new file mode 100644 index 00000000..7dfab058 --- /dev/null +++ b/apps/desktop/src/main/updater/index.ts @@ -0,0 +1,121 @@ +/** + * Auto-updater module using electron-updater + * Checks for updates from GitHub releases and handles download/install + */ +import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater' +import { BrowserWindow } from 'electron' + +export interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: ProgressInfo + error?: string +} + +export class AutoUpdater { + private mainWindow: (() => BrowserWindow | null) | null = null + + constructor(forceDevUpdateConfig = false) { + // Configure auto-updater + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + + // Enable update checking in dev mode for testing + if (forceDevUpdateConfig) { + autoUpdater.forceDevUpdateConfig = true + console.log('[AutoUpdater] Force dev update config enabled') + } + + // Enable logging + autoUpdater.logger = { + info: (msg) => console.log('[AutoUpdater]', msg), + warn: (msg) => console.warn('[AutoUpdater]', msg), + error: (msg) => console.error('[AutoUpdater]', msg), + debug: (msg) => console.log('[AutoUpdater:debug]', msg) + } + + // Set up event handlers + autoUpdater.on('checking-for-update', () => { + this.sendStatus({ status: 'checking' }) + }) + + autoUpdater.on('update-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'available', info }) + }) + + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'not-available', info }) + }) + + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + this.sendStatus({ status: 'downloading', progress }) + }) + + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + this.sendStatus({ status: 'downloaded', info }) + }) + + autoUpdater.on('error', (err: Error) => { + this.sendStatus({ status: 'error', error: err.message }) + }) + } + + /** + * Set the main window reference for sending IPC messages + */ + setMainWindow(getWindow: () => BrowserWindow | null): void { + this.mainWindow = getWindow + } + + /** + * Check for updates + */ + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates() + } catch (err) { + console.error('[AutoUpdater] Check for updates failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error' + }) + } + } + + /** + * Download the available update + */ + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate() + } catch (err) { + console.error('[AutoUpdater] Download update failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Download failed' + }) + } + } + + /** + * Quit and install the downloaded update + */ + quitAndInstall(): void { + autoUpdater.quitAndInstall() + } + + /** + * Send update status to renderer + */ + private sendStatus(status: UpdateStatus): void { + const window = this.mainWindow?.() + if (window && !window.isDestroyed()) { + window.webContents.send('update:status', status) + } + } +} + +// Factory function to create updater with options +export function createUpdater(forceDevUpdateConfig = false): AutoUpdater { + return new AutoUpdater(forceDevUpdateConfig) +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 44acd172..528da18a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -237,6 +237,26 @@ const electronAPI = { wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason), }, + // Auto-update + update: { + /** Check for updates */ + check: () => ipcRenderer.invoke('update:check'), + /** Download the available update */ + download: () => ipcRenderer.invoke('update:download'), + /** Quit and install the downloaded update */ + install: () => ipcRenderer.invoke('update:install'), + /** Listen for update status changes (returns unsubscribe function) */ + onStatus: (callback: (status: { status: string; info?: unknown; progress?: unknown; error?: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: Parameters[0]): void => { + callback(status) + } + ipcRenderer.on('update:status', listener) + return (): void => { + ipcRenderer.removeListener('update:status', listener) + } + }, + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */ diff --git a/apps/desktop/src/renderer/src/components/update-notification.tsx b/apps/desktop/src/renderer/src/components/update-notification.tsx new file mode 100644 index 00000000..4eb85fdf --- /dev/null +++ b/apps/desktop/src/renderer/src/components/update-notification.tsx @@ -0,0 +1,144 @@ +/** + * Update notification component + * Shows when a new version is available and allows user to download/install + */ +import { useState, useEffect } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Download04Icon, + Loading03Icon, + CheckmarkCircle02Icon, + AlertCircleIcon, + Cancel01Icon, +} from '@hugeicons/core-free-icons' +import { Button } from '@multica/ui/components/ui/button' + +interface UpdateInfo { + version: string + releaseDate?: string + releaseNotes?: string | null +} + +interface UpdateProgress { + percent: number + bytesPerSecond: number + total: number + transferred: number +} + +interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: UpdateProgress + error?: string +} + +export function UpdateNotification(): React.JSX.Element | null { + const [updateStatus, setUpdateStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const unsubscribe = window.electronAPI.update.onStatus((status: UpdateStatus) => { + setUpdateStatus(status) + // Reset dismissed state when a new update becomes available + if (status.status === 'available') { + setDismissed(false) + } + }) + + return () => unsubscribe() + }, []) + + const handleDownload = async (): Promise => { + await window.electronAPI.update.download() + } + + const handleInstall = (): void => { + window.electronAPI.update.install() + } + + const handleDismiss = (): void => { + setDismissed(true) + } + + // Don't show if dismissed or no relevant status + if (dismissed) return null + if (!updateStatus) return null + if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null + + const version = updateStatus.info?.version + const isError = updateStatus.status === 'error' + + return ( +
+
+ {/* Icon */} +
+ {isError ? ( + + ) : updateStatus.status === 'downloaded' ? ( + + ) : updateStatus.status === 'downloading' ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ + {isError + ? 'Update failed' + : updateStatus.status === 'downloaded' + ? 'Update ready' + : updateStatus.status === 'downloading' + ? 'Downloading update...' + : 'Update available'} + + + {isError + ? 'Please download manually from GitHub' + : updateStatus.status === 'downloading' && updateStatus.progress + ? `${Math.round(updateStatus.progress.percent)}%` + : version + ? `Version ${version}` + : 'New version available'} + +
+ + {/* Actions */} +
+ {updateStatus.status === 'available' && ( + + )} + {updateStatus.status === 'downloaded' && ( + + )} + {isError && ( + + )} + {updateStatus.status !== 'downloading' && ( + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index e1d9cb1d..9146c90f 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -13,6 +13,7 @@ import { } from '@hugeicons/core-free-icons' import { cn } from '@multica/ui/lib/utils' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' +import { UpdateNotification } from '../components/update-notification' import ChatPage from './chat' const tabs = [ @@ -85,6 +86,7 @@ export default function Layout() { + ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f1db6aa..70b7bad9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@multica/ui': specifier: workspace:* version: link:../../packages/ui + electron-updater: + specifier: ^6.7.3 + version: 6.7.3 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -417,7 +420,7 @@ importers: version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.23 - version: 6.0.23(x2t3rftrl3zckf7etg5727wz3e) + version: 6.0.23(8dceef04c573932de716d76b2aeca19d) expo-splash-screen: specifier: ~31.0.13 version: 31.0.13(expo@54.0.33) @@ -597,13 +600,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@multica/types': specifier: workspace:* version: link:../types @@ -2322,89 +2325,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2569,30 +2588,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.2': resolution: {integrity: sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.2': resolution: {integrity: sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.2': resolution: {integrity: sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.2': resolution: {integrity: sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.2': resolution: {integrity: sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==} @@ -2770,24 +2794,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -3289,66 +3317,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -3673,24 +3714,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -4201,41 +4246,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -5356,6 +5409,9 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-updater@6.7.3: + resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} + electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7005,72 +7061,84 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} @@ -7161,6 +7229,13 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -9339,6 +9414,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -10149,12 +10227,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 3.25.76 - '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -11780,7 +11852,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.19.0 optionalDependencies: - expo-router: 6.0.23(x2t3rftrl3zckf7etg5727wz3e) + expo-router: 6.0.23(8dceef04c573932de716d76b2aeca19d) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -12029,18 +12101,6 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.40.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))': - dependencies: - google-auth-library: 10.5.0 - protobufjs: 7.5.4 - ws: 8.19.0 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.40.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 @@ -12389,18 +12449,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@mariozechner/pi-ai': 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) @@ -12413,30 +12461,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.986.0 - '@google/genai': 1.40.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)) - '@mistralai/mistralai': 1.10.0 - '@sinclair/typebox': 0.34.48 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - chalk: 5.6.2 - openai: 6.10.0(ws@8.19.0)(zod@3.25.76) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.21.0 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -12461,35 +12485,6 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': - dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-ai': 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-tui': 0.52.9 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - file-type: 21.3.0 - glob: 13.0.1 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - marked: 15.0.12 - minimatch: 10.1.2 - proper-lockfile: 4.1.2 - yaml: 2.8.2 - optionalDependencies: - '@mariozechner/clipboard': 0.3.2 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-coding-agent@0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -15557,6 +15552,19 @@ snapshots: electron-to-chromium@1.5.286: {} + electron-updater@6.7.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@5.0.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.31.1)(terser@5.46.0)): dependencies: '@babel/core': 7.29.0 @@ -16291,7 +16299,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0) - expo-router@6.0.23(x2t3rftrl3zckf7etg5727wz3e): + expo-router@6.0.23(8dceef04c573932de716d76b2aeca19d): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 @@ -17776,6 +17784,10 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash.throttle@4.1.1: {} @@ -18838,11 +18850,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.10.0(ws@8.19.0)(zod@3.25.76): - optionalDependencies: - ws: 8.19.0 - zod: 3.25.76 - openai@6.10.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 @@ -20696,6 +20703,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} From d47594d7b2184cc8ad9597afd8a72827adae5601 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 16:43:58 +0800 Subject: [PATCH 02/30] feat(core): add unified data tool with finance domain support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `data` tool that provides structured access to the Financial Datasets API (financialdatasets.ai). Supports 18 finance actions covering stock prices, financial statements, key metrics, SEC filings, analyst estimates, insider trades, news, and crypto data. Designed as a stable interface — the backend can be swapped from direct API calls to a Multica Data Service without changing the tool schema. Co-Authored-By: Claude Opus 4.6 --- .../core/src/agent/system-prompt/sections.ts | 13 + packages/core/src/agent/tools.ts | 3 + .../core/src/agent/tools/data/data-tool.ts | 118 +++++++ .../src/agent/tools/data/finance/actions.ts | 295 ++++++++++++++++++ .../core/src/agent/tools/data/finance/api.ts | 74 +++++ .../src/agent/tools/data/finance/types.ts | 35 +++ packages/core/src/agent/tools/data/index.ts | 1 + packages/core/src/agent/tools/groups.ts | 4 + packages/core/src/agent/tools/index.ts | 1 + 9 files changed, 544 insertions(+) create mode 100644 packages/core/src/agent/tools/data/data-tool.ts create mode 100644 packages/core/src/agent/tools/data/finance/actions.ts create mode 100644 packages/core/src/agent/tools/data/finance/api.ts create mode 100644 packages/core/src/agent/tools/data/finance/types.ts create mode 100644 packages/core/src/agent/tools/data/index.ts diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index f0db6e33..6d696e7d 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -28,6 +28,7 @@ const CORE_TOOL_SUMMARIES: Record = { web_fetch: "Fetch and extract readable content from a URL", memory_search: "Search memory files by keyword", sessions_spawn: "Spawn a sub-agent session", + data: "Query structured financial and market data", }; /** Preferred display order for tools */ @@ -42,6 +43,7 @@ const TOOL_ORDER = [ "web_fetch", "memory_search", "sessions_spawn", + "data", ]; // ─── Section builders ─────────────────────────────────────────────────────── @@ -266,6 +268,17 @@ export function buildConditionalToolSections( ); } + // Data tools + if (toolSet.has("data")) { + lines.push( + "## Data Access", + "You have access to structured financial and market data via the `data` tool.", + 'Use domain="finance" with specific actions to retrieve stock prices, financial statements, SEC filings, metrics, and more.', + "Always specify dates in YYYY-MM-DD format. Use period='annual' or 'quarterly' or 'ttm' for financial statements.", + "", + ); + } + // Web tools if (toolSet.has("web_search") || toolSet.has("web_fetch")) { lines.push( diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index f3908d73..7905fc74 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -10,6 +10,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn.js"; import { createSessionsListTool } from "./tools/sessions-list.js"; import { createMemorySearchTool } from "./tools/memory-search.js"; import { createCronTool } from "./tools/cron/index.js"; +import { createDataTool } from "./tools/data/index.js"; import { filterTools } from "./tools/policy.js"; import { isMulticaError, isRetryableError } from "@multica/utils"; import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; @@ -110,6 +111,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const webSearchTool = createWebSearchTool(); const cronTool = createCronTool(); + const dataTool = createDataTool(); const tools: AgentTool[] = [ ...baseTools, @@ -119,6 +121,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< webFetchTool as AgentTool, webSearchTool as AgentTool, cronTool as AgentTool, + dataTool as AgentTool, ]; // Add memory_search tool if profileDir is provided diff --git a/packages/core/src/agent/tools/data/data-tool.ts b/packages/core/src/agent/tools/data/data-tool.ts new file mode 100644 index 00000000..b4bb2e99 --- /dev/null +++ b/packages/core/src/agent/tools/data/data-tool.ts @@ -0,0 +1,118 @@ +/** + * Unified data tool — structured access to domain-specific data sources. + * + * Currently supports the "finance" domain (Financial Datasets API). + * Designed as a stable interface: the backend can be swapped from + * direct API calls to a Multica Data Service without changing the tool schema. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { executeFinanceAction } from "./finance/actions.js"; + +// ─── Schema ───────────────────────────────────────────────────────────────── + +const DataToolSchema = Type.Object({ + domain: Type.String({ + description: 'Data domain. Currently supported: "finance".', + }), + action: Type.String({ + description: + "Action to perform within the domain.\n\n" + + "FINANCE DOMAIN ACTIONS:\n" + + "Prices:\n" + + " get_price_snapshot — params: { ticker }\n" + + " get_prices — params: { ticker, start_date, end_date, interval?, interval_multiplier? }\n" + + " get_crypto_price_snapshot — params: { ticker } (e.g. BTC-USD)\n" + + " get_crypto_prices — params: { ticker, start_date, end_date, interval?, interval_multiplier? }\n" + + " get_available_crypto_tickers — params: {}\n" + + "Financial Statements (period: annual|quarterly|ttm):\n" + + " get_income_statements — params: { ticker, period, limit?, report_period_gt/gte/lt/lte? }\n" + + " get_balance_sheets — same params\n" + + " get_cash_flow_statements — same params\n" + + " get_all_financial_statements — same params (returns all three)\n" + + "Metrics:\n" + + " get_financial_metrics_snapshot — params: { ticker }\n" + + " get_financial_metrics — params: { ticker, period?, limit?, report_period*? }\n" + + " get_analyst_estimates — params: { ticker, period? }\n" + + "Company:\n" + + " get_news — params: { ticker, start_date?, end_date?, limit? }\n" + + " get_insider_trades — params: { ticker, limit?, filing_date*? }\n" + + " get_segmented_revenues — params: { ticker, period, limit? }\n" + + " get_company_facts — params: { ticker }\n" + + "SEC Filings:\n" + + " get_filings — params: { ticker, filing_type?, limit? }\n" + + " get_filing_items — params: { ticker, filing_type, accession_number?, item? }", + }), + params: Type.Record(Type.String(), Type.Unknown(), { + description: + "Action-specific parameters as key-value pairs. " + + "Common: ticker (string, e.g. 'AAPL'), period ('annual'|'quarterly'|'ttm'), " + + "limit (number), start_date/end_date ('YYYY-MM-DD'), " + + "interval ('day'|'week'|'month'|'year'), filing_type ('10-K'|'10-Q'|'8-K').", + }), +}); + +// ─── Types ────────────────────────────────────────────────────────────────── + +type DataToolArgs = { + domain: string; + action: string; + params: Record; +}; + +export type DataToolResult = { + domain: string; + action: string; + data: unknown; + sourceUrl?: string; +}; + +// ─── Factory ──────────────────────────────────────────────────────────────── + +export function createDataTool(): AgentTool { + return { + name: "data", + label: "Data", + description: + "Query structured data from external sources. " + + 'Supports domain="finance" for stock prices, financial statements, key metrics, ' + + "SEC filings, analyst estimates, insider trades, news, and crypto data.", + parameters: DataToolSchema, + execute: async (_toolCallId, args, signal) => { + const { domain, action, params } = args as DataToolArgs; + + if (domain !== "finance") { + const errorPayload = { + error: true, + message: `Unknown domain: "${domain}". Currently supported: "finance".`, + }; + return { + content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], + details: { domain, action, data: null } as unknown as DataToolResult, + }; + } + + try { + const result = await executeFinanceAction(action, params ?? {}, signal); + const payload: DataToolResult = { + domain, + action, + data: result.data, + sourceUrl: result.sourceUrl, + }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorPayload = { error: true, domain, action, message }; + return { + content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], + details: { domain, action, data: null } as unknown as DataToolResult, + }; + } + }, + }; +} diff --git a/packages/core/src/agent/tools/data/finance/actions.ts b/packages/core/src/agent/tools/data/finance/actions.ts new file mode 100644 index 00000000..557b9fcf --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/actions.ts @@ -0,0 +1,295 @@ +/** + * Finance action handlers. + * + * Maps each action name to the corresponding Financial Datasets API call. + */ + +import type { FinanceAction } from "./types.js"; +import { FINANCE_ACTION_SET } from "./types.js"; +import { financeFetch } from "./api.js"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +type Params = Record; + +function requireParam(params: Params, name: string): string { + const value = params[name]; + if (value === undefined || value === null || value === "") { + throw new Error(`Missing required parameter: ${name}`); + } + return String(value); +} + +function optionalString(params: Params, name: string): string | undefined { + const value = params[name]; + if (value === undefined || value === null || value === "") return undefined; + return String(value); +} + +function optionalNumber(params: Params, name: string): number | undefined { + const value = params[name]; + if (value === undefined || value === null) return undefined; + return Number(value); +} + +function optionalStringArray(params: Params, name: string): string[] | undefined { + const value = params[name]; + if (value === undefined || value === null) return undefined; + if (Array.isArray(value)) return value.map(String); + return [String(value)]; +} + +/** Extract common financial statement filter params */ +function statementFilters(params: Params) { + return { + ticker: requireParam(params, "ticker"), + period: requireParam(params, "period"), + limit: optionalNumber(params, "limit"), + report_period_gt: optionalString(params, "report_period_gt"), + report_period_gte: optionalString(params, "report_period_gte"), + report_period_lt: optionalString(params, "report_period_lt"), + report_period_lte: optionalString(params, "report_period_lte"), + }; +} + +// ─── Action result type ───────────────────────────────────────────────────── + +export interface FinanceActionResult { + data: unknown; + sourceUrl: string; +} + +// ─── Action handlers ──────────────────────────────────────────────────────── + +type ActionHandler = (params: Params, signal?: AbortSignal) => Promise; + +const handlers: Record = { + // ── Prices ────────────────────────────────────────────────────────────── + + get_price_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/prices/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_prices: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const start_date = requireParam(params, "start_date"); + const end_date = requireParam(params, "end_date"); + const { data, url } = await financeFetch( + "/prices", + { + ticker, + start_date, + end_date, + interval: optionalString(params, "interval") ?? "day", + interval_multiplier: optionalNumber(params, "interval_multiplier"), + }, + signal, + ); + return { data: (data as Record).prices ?? data, sourceUrl: url }; + }, + + get_crypto_price_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/crypto/prices/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_crypto_prices: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const start_date = requireParam(params, "start_date"); + const end_date = requireParam(params, "end_date"); + const { data, url } = await financeFetch( + "/crypto/prices", + { + ticker, + start_date, + end_date, + interval: optionalString(params, "interval") ?? "day", + interval_multiplier: optionalNumber(params, "interval_multiplier"), + }, + signal, + ); + return { data: (data as Record).prices ?? data, sourceUrl: url }; + }, + + get_available_crypto_tickers: async (_params, signal) => { + const { data, url } = await financeFetch("/crypto/prices/tickers", {}, signal); + return { data: (data as Record).tickers ?? data, sourceUrl: url }; + }, + + // ── Financial statements ──────────────────────────────────────────────── + + get_income_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/income-statements", filters, signal); + return { data: (data as Record).income_statements ?? data, sourceUrl: url }; + }, + + get_balance_sheets: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/balance-sheets", filters, signal); + return { data: (data as Record).balance_sheets ?? data, sourceUrl: url }; + }, + + get_cash_flow_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/cash-flow-statements", filters, signal); + return { data: (data as Record).cash_flow_statements ?? data, sourceUrl: url }; + }, + + get_all_financial_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials", filters, signal); + return { data: (data as Record).financials ?? data, sourceUrl: url }; + }, + + // ── Metrics & estimates ───────────────────────────────────────────────── + + get_financial_metrics_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/financial-metrics/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_financial_metrics: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/financial-metrics", + { + ticker, + period: optionalString(params, "period"), + limit: optionalNumber(params, "limit"), + report_period: optionalString(params, "report_period"), + report_period_gt: optionalString(params, "report_period_gt"), + report_period_gte: optionalString(params, "report_period_gte"), + report_period_lt: optionalString(params, "report_period_lt"), + report_period_lte: optionalString(params, "report_period_lte"), + }, + signal, + ); + return { data: (data as Record).financial_metrics ?? data, sourceUrl: url }; + }, + + get_analyst_estimates: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/analyst-estimates", + { + ticker, + period: optionalString(params, "period"), + }, + signal, + ); + return { data: (data as Record).analyst_estimates ?? data, sourceUrl: url }; + }, + + // ── Company info ──────────────────────────────────────────────────────── + + get_news: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/news", + { + ticker, + start_date: optionalString(params, "start_date"), + end_date: optionalString(params, "end_date"), + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).news ?? data, sourceUrl: url }; + }, + + get_insider_trades: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/insider-trades", + { + ticker: ticker.toUpperCase(), + limit: optionalNumber(params, "limit"), + filing_date: optionalString(params, "filing_date"), + filing_date_gt: optionalString(params, "filing_date_gt"), + filing_date_gte: optionalString(params, "filing_date_gte"), + filing_date_lt: optionalString(params, "filing_date_lt"), + filing_date_lte: optionalString(params, "filing_date_lte"), + }, + signal, + ); + return { data: (data as Record).insider_trades ?? data, sourceUrl: url }; + }, + + get_segmented_revenues: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const period = requireParam(params, "period"); + const { data, url } = await financeFetch( + "/financials/segmented-revenues", + { + ticker, + period, + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).segmented_revenues ?? data, sourceUrl: url }; + }, + + get_company_facts: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/company/facts", { ticker }, signal); + return { data: (data as Record).company_facts ?? data, sourceUrl: url }; + }, + + // ── SEC filings ───────────────────────────────────────────────────────── + + get_filings: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/filings", + { + ticker, + filing_type: optionalString(params, "filing_type"), + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).filings ?? data, sourceUrl: url }; + }, + + get_filing_items: async (params, signal) => { + const ticker = requireParam(params, "ticker").toUpperCase(); + const filing_type = requireParam(params, "filing_type"); + const { data, url } = await financeFetch( + "/filings/items", + { + ticker, + filing_type, + accession_number: optionalString(params, "accession_number"), + item: optionalStringArray(params, "item"), + }, + signal, + ); + return { data, sourceUrl: url }; + }, +}; + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Execute a finance domain action. + * + * @throws Error if the action is unknown or required params are missing. + */ +export function executeFinanceAction( + action: string, + params: Record, + signal?: AbortSignal, +): Promise { + if (!FINANCE_ACTION_SET.has(action)) { + throw new Error( + `Unknown finance action: "${action}". Available: ${[...FINANCE_ACTION_SET].join(", ")}`, + ); + } + return handlers[action as FinanceAction](params, signal); +} diff --git a/packages/core/src/agent/tools/data/finance/api.ts b/packages/core/src/agent/tools/data/finance/api.ts new file mode 100644 index 00000000..c775291b --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/api.ts @@ -0,0 +1,74 @@ +/** + * Financial Datasets API client. + * + * Base URL: https://api.financialdatasets.ai + * Auth: X-API-KEY header + * All endpoints use GET with query parameters. + */ + +import { credentialManager } from "../../../credentials.js"; + +const BASE_URL = "https://api.financialdatasets.ai"; +const TIMEOUT_MS = 30_000; + +function getApiKey(): string { + const key = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY"); + if (!key) { + throw new Error( + "FINANCIAL_DATASETS_API_KEY not configured. " + + "Set it in ~/.super-multica/skills.env.json5 under env, or as an environment variable.", + ); + } + return key; +} + +/** + * Fetch data from the Financial Datasets API. + * + * @param path - API path (e.g., "/prices/snapshot") + * @param params - Query parameters. Arrays are sent as repeated params (e.g., item=1A&item=1B). + * @param signal - Optional AbortSignal for cancellation. + */ +export async function financeFetch>( + path: string, + params: Record, + signal?: AbortSignal, +): Promise<{ data: T; url: string }> { + const apiKey = getApiKey(); + + const url = new URL(path, BASE_URL); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const v of value) { + url.searchParams.append(key, String(v)); + } + } else { + url.searchParams.set(key, String(value)); + } + } + + const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + "X-API-KEY": apiKey, + Accept: "application/json", + }, + signal: combinedSignal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Financial Datasets API error (${res.status}): ${body || res.statusText}`, + ); + } + + const data = (await res.json()) as T; + return { data, url: url.toString() }; +} diff --git a/packages/core/src/agent/tools/data/finance/types.ts b/packages/core/src/agent/tools/data/finance/types.ts new file mode 100644 index 00000000..82fa4342 --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/types.ts @@ -0,0 +1,35 @@ +/** + * Finance domain types for the data tool. + */ + +/** All supported finance actions */ +export const FINANCE_ACTIONS = [ + // Price data + "get_price_snapshot", + "get_prices", + "get_crypto_price_snapshot", + "get_crypto_prices", + "get_available_crypto_tickers", + // Financial statements + "get_income_statements", + "get_balance_sheets", + "get_cash_flow_statements", + "get_all_financial_statements", + // Metrics & estimates + "get_financial_metrics_snapshot", + "get_financial_metrics", + "get_analyst_estimates", + // Company info + "get_news", + "get_insider_trades", + "get_segmented_revenues", + "get_company_facts", + // SEC filings + "get_filings", + "get_filing_items", +] as const; + +export type FinanceAction = (typeof FINANCE_ACTIONS)[number]; + +/** Set for O(1) lookup */ +export const FINANCE_ACTION_SET = new Set(FINANCE_ACTIONS); diff --git a/packages/core/src/agent/tools/data/index.ts b/packages/core/src/agent/tools/data/index.ts new file mode 100644 index 00000000..7712b081 --- /dev/null +++ b/packages/core/src/agent/tools/data/index.ts @@ -0,0 +1 @@ +export { createDataTool, type DataToolResult } from "./data-tool.js"; diff --git a/packages/core/src/agent/tools/groups.ts b/packages/core/src/agent/tools/groups.ts index f61c9037..a2bbed12 100644 --- a/packages/core/src/agent/tools/groups.ts +++ b/packages/core/src/agent/tools/groups.ts @@ -39,6 +39,9 @@ export const TOOL_GROUPS: Record = { // Cron/scheduling tools "group:cron": ["cron"], + // Data tools (finance, etc.) + "group:data": ["data"], + // All core tools "group:core": [ "read", @@ -49,6 +52,7 @@ export const TOOL_GROUPS: Record = { "process", "web_search", "web_fetch", + "data", ], }; diff --git a/packages/core/src/agent/tools/index.ts b/packages/core/src/agent/tools/index.ts index e225c54c..3e148e1a 100644 --- a/packages/core/src/agent/tools/index.ts +++ b/packages/core/src/agent/tools/index.ts @@ -8,6 +8,7 @@ export { createProcessTool } from "./process.js"; export { createGlobTool } from "./glob.js"; export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; export { createCronTool } from "./cron/index.js"; +export { createDataTool } from "./data/index.js"; export { createSessionsListTool } from "./sessions-list.js"; // Tool groups From 6e28513ec5b15bebc18ff172b41dcd08f0829551 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 16:44:05 +0800 Subject: [PATCH 03/30] feat(skills): add finance research and DCF valuation skills - finance-research: guides agent on financial data retrieval and analysis workflows using the data tool - dcf-valuation: 8-step DCF valuation workflow with sector WACC reference data, sensitivity analysis, and validation checks Co-Authored-By: Claude Opus 4.6 --- skills/dcf-valuation/SKILL.md | 208 ++++++++++++++++++ .../dcf-valuation/references/sector-wacc.md | 40 ++++ skills/finance-research/SKILL.md | 91 ++++++++ 3 files changed, 339 insertions(+) create mode 100644 skills/dcf-valuation/SKILL.md create mode 100644 skills/dcf-valuation/references/sector-wacc.md create mode 100644 skills/finance-research/SKILL.md diff --git a/skills/dcf-valuation/SKILL.md b/skills/dcf-valuation/SKILL.md new file mode 100644 index 00000000..f2f6dc7b --- /dev/null +++ b/skills/dcf-valuation/SKILL.md @@ -0,0 +1,208 @@ +--- +name: DCF Valuation +description: Perform Discounted Cash Flow (DCF) valuation analysis for public companies. Use when the user asks to value a stock, calculate intrinsic value, fair value, perform DCF analysis, determine if a stock is undervalued or overvalued, or estimate a price target. +version: 1.0.0 +metadata: + emoji: "\U0001F9EE" + requires: + env: + - FINANCIAL_DATASETS_API_KEY + tags: + - finance + - valuation + - dcf +userInvocable: true +disableModelInvocation: false +--- + +## Instructions + +Perform a rigorous Discounted Cash Flow (DCF) valuation. Follow all steps and show your work. + +### Progress Checklist + +``` +DCF Analysis Progress: +- [ ] Step 1: Gather financial data +- [ ] Step 2: Calculate historical FCF and growth +- [ ] Step 3: Estimate WACC +- [ ] Step 4: Project future cash flows +- [ ] Step 5: Calculate present value and fair value +- [ ] Step 6: Sensitivity analysis +- [ ] Step 7: Validate results +- [ ] Step 8: Present findings +``` + +### Step 1: Gather Financial Data + +Use `data` tool with `domain="finance"` for all calls: + +1. **Cash Flow History** (5 years): + ``` + action: "get_cash_flow_statements" + params: { ticker: "[TICKER]", period: "annual", limit: 5 } + ``` + Extract: `free_cash_flow`, `net_cash_flow_from_operations`, `capital_expenditure` + Fallback: FCF = Operating Cash Flow - CapEx + +2. **Income Statements** (5 years): + ``` + action: "get_income_statements" + params: { ticker: "[TICKER]", period: "annual", limit: 5 } + ``` + Extract: `revenue`, `operating_income`, `net_income`, `income_tax_expense` + +3. **Balance Sheet** (latest): + ``` + action: "get_balance_sheets" + params: { ticker: "[TICKER]", period: "annual", limit: 1 } + ``` + Extract: `total_debt`, `cash_and_equivalents`, `outstanding_shares` + +4. **Financial Metrics** (current): + ``` + action: "get_financial_metrics_snapshot" + params: { ticker: "[TICKER]" } + ``` + Extract: `market_cap`, `enterprise_value`, `return_on_invested_capital`, `debt_to_equity`, `free_cash_flow_per_share` + +5. **Analyst Estimates**: + ``` + action: "get_analyst_estimates" + params: { ticker: "[TICKER]", period: "annual" } + ``` + Extract: Forward EPS estimates for growth validation + +6. **Current Price**: + ``` + action: "get_price_snapshot" + params: { ticker: "[TICKER]" } + ``` + +7. **Company Facts**: + ``` + action: "get_company_facts" + params: { ticker: "[TICKER]" } + ``` + Extract: `sector` — use to determine WACC range from [sector-wacc.md](references/sector-wacc.md) + +### Step 2: Calculate Historical FCF and Growth + +- Compute FCF for each of the last 5 years +- Calculate 5-year FCF CAGR: `(FCF_latest / FCF_earliest)^(1/years) - 1` +- Cross-validate with: revenue growth, operating income growth, analyst EPS growth +- **Cap projected growth at 15%** (sustained higher growth is rare) +- If FCF is volatile, weight analyst estimates more heavily + +### Step 3: Estimate WACC + +Use the company's `sector` to look up the base WACC range from [sector-wacc.md](references/sector-wacc.md). + +**Calculate WACC:** +``` +WACC = (E/V) * Re + (D/V) * Rd * (1 - Tax Rate) + +Where: + E = Market cap (equity value) + D = Total debt + V = E + D + Re = Risk-free rate + Beta * Equity Risk Premium + Rd = Cost of debt (estimate from interest expense / total debt) + Tax Rate = Effective tax rate from income statements +``` + +**Default assumptions:** +- Risk-free rate: ~4.0-4.5% (10-year Treasury) +- Equity risk premium: ~5.5% +- If beta unavailable, use sector average + +**Sanity check:** WACC should be 2-4% below ROIC for value-creating companies. + +### Step 4: Project Future Cash Flows (Years 1-5) + +- Apply growth rate with annual decay (multiply by 0.95 each year) +- Year 1: FCF * (1 + growth_rate) +- Year 2: FCF * (1 + growth_rate * 0.95) +- Year 3: FCF * (1 + growth_rate * 0.90) +- Year 4: FCF * (1 + growth_rate * 0.85) +- Year 5: FCF * (1 + growth_rate * 0.80) + +**Terminal Value** (Gordon Growth Model): +``` +TV = FCF_Year5 * (1 + g) / (WACC - g) +Where g = terminal growth rate (2.5% default, GDP proxy) +``` + +### Step 5: Calculate Present Value and Fair Value + +``` +PV of each FCF = FCF_t / (1 + WACC)^t +PV of Terminal Value = TV / (1 + WACC)^5 + +Enterprise Value = Sum of PV(FCFs) + PV(Terminal Value) +Net Debt = Total Debt - Cash and Equivalents +Equity Value = Enterprise Value - Net Debt +Fair Value per Share = Equity Value / Shares Outstanding +``` + +### Step 6: Sensitivity Analysis + +Create a matrix varying two key assumptions: + +| | TG 2.0% | TG 2.5% | TG 3.0% | +|---|---|---|---| +| **WACC -1%** | $ | $ | $ | +| **WACC base** | $ | $ | $ | +| **WACC +1%** | $ | $ | $ | + +(TG = Terminal Growth Rate) + +### Step 7: Validate Results + +Before presenting, check: + +1. **EV comparison**: Calculated EV within 30% of reported enterprise_value + - If off by >30%, revisit WACC or growth assumptions +2. **Terminal value ratio**: Should be 50-80% of total EV for mature companies + - If >90%, growth rate may be too high + - If <40%, near-term projections may be aggressive +3. **FCF yield check**: Compare fair value FCF yield to current market FCF yield + +If validation fails, adjust assumptions and recalculate. + +### Step 8: Present Results + +Format clearly with: + +1. **Executive Summary** + - Current price vs. fair value estimate + - Upside/downside percentage + - Verdict: Undervalued / Fairly Valued / Overvalued + +2. **Key Assumptions Table** + | Assumption | Value | Source | + |---|---|---| + | Growth Rate | X% | 5Y CAGR + analyst cross-check | + | WACC | X% | Sector range + company adjustments | + | Terminal Growth | X% | GDP proxy | + | Tax Rate | X% | Effective rate from financials | + +3. **Projected FCF Table** + | Year | FCF | Growth | PV of FCF | + |---|---|---|---| + +4. **Valuation Bridge** + - PV of projected FCFs + - PV of Terminal Value + - = Enterprise Value + - - Net Debt + - = Equity Value + - / Shares Outstanding + - = **Fair Value per Share** + +5. **Sensitivity Matrix** (from Step 6) + +6. **Risks & Caveats** + - Key risks to the valuation thesis + - DCF limitations (sensitive to growth and WACC assumptions) + - Company-specific caveats (high debt, cyclicality, early-stage, etc.) diff --git a/skills/dcf-valuation/references/sector-wacc.md b/skills/dcf-valuation/references/sector-wacc.md new file mode 100644 index 00000000..5ddc1bd1 --- /dev/null +++ b/skills/dcf-valuation/references/sector-wacc.md @@ -0,0 +1,40 @@ +# Sector WACC Reference + +Use the company's `sector` from `get_company_facts` to look up the base WACC range below, then adjust for company-specific factors. + +## WACC by Sector + +| Sector | Typical WACC Range | Notes | +|--------|-------------------|-------| +| Communication Services | 8-10% | Mix of stable telecom and growth media | +| Consumer Discretionary | 8-10% | Cyclical exposure | +| Consumer Staples | 7-8% | Defensive, stable demand | +| Energy | 9-11% | Commodity price exposure | +| Financials | 8-10% | Leverage already in business model | +| Health Care | 8-10% | Regulatory and pipeline risk | +| Industrials | 8-9% | Moderate cyclicality | +| Information Technology | 8-12% | Higher end for high-growth; lower for mature | +| Materials | 8-10% | Cyclical, commodity exposure | +| Real Estate | 7-9% | Interest rate sensitivity | +| Utilities | 6-7% | Regulated, stable cash flows | + +## Adjustment Factors + +**Add to base WACC:** +- High debt (D/E > 1.5): +1-2% +- Small cap (< $2B market cap): +1-2% +- Emerging markets exposure: +1-3% +- Concentrated customer base: +0.5-1% +- Regulatory uncertainty: +0.5-1.5% + +**Subtract from base WACC:** +- Market leader with moat: -0.5-1% +- Recurring revenue model: -0.5-1% +- Investment grade credit: -0.5% + +## Sanity Checks + +- WACC should typically be 2-4% below ROIC for value-creating companies +- If WACC > ROIC, the company may be destroying value +- Typical range for US large-cap: 7-12% +- Anything below 6% or above 14% warrants extra scrutiny diff --git a/skills/finance-research/SKILL.md b/skills/finance-research/SKILL.md new file mode 100644 index 00000000..acf225d7 --- /dev/null +++ b/skills/finance-research/SKILL.md @@ -0,0 +1,91 @@ +--- +name: Finance Research +description: Conduct financial research and analysis including stock analysis, company fundamentals, SEC filings review, and market data retrieval. Use when the user asks about stocks, financial statements, company performance, market data, or investment analysis. +version: 1.0.0 +metadata: + emoji: "\U0001F4CA" + requires: + env: + - FINANCIAL_DATASETS_API_KEY + tags: + - finance + - research + - stocks + - data +userInvocable: true +disableModelInvocation: false +--- + +## Instructions + +You are conducting financial research using real market data. Use the `data` tool with `domain="finance"` and the appropriate action. + +### Available Data Actions + +#### Price Data +- `get_price_snapshot` — Current stock price. Params: `{ ticker }` +- `get_prices` — Historical OHLCV prices. Params: `{ ticker, start_date, end_date, interval?, interval_multiplier? }` + - interval: "day" (default), "week", "month", "year" +- `get_crypto_price_snapshot` — Current crypto price. Params: `{ ticker }` (e.g. "BTC-USD") +- `get_crypto_prices` — Historical crypto prices. Same params as get_prices. +- `get_available_crypto_tickers` — List available crypto tickers. Params: `{}` + +#### Financial Statements +All share params: `{ ticker, period, limit?, report_period_gt?, report_period_gte?, report_period_lt?, report_period_lte? }` +- period: "annual", "quarterly", or "ttm" +- Dates in YYYY-MM-DD format + +Actions: +- `get_income_statements` — Revenue, expenses, net income, EPS +- `get_balance_sheets` — Assets, liabilities, equity, debt, cash +- `get_cash_flow_statements` — Operating, investing, financing cash flows, FCF +- `get_all_financial_statements` — All three at once (more efficient when you need multiple) + +#### Metrics & Estimates +- `get_financial_metrics_snapshot` — Current key ratios (P/E, market cap, margins, etc.). Params: `{ ticker }` +- `get_financial_metrics` — Historical metrics. Params: `{ ticker, period?, limit?, report_period*? }` +- `get_analyst_estimates` — EPS and revenue estimates. Params: `{ ticker, period? }` + +#### Company Info +- `get_company_facts` — Sector, industry, employees, exchange, website. Params: `{ ticker }` +- `get_news` — Recent news articles. Params: `{ ticker, start_date?, end_date?, limit? }` +- `get_insider_trades` — Insider buying/selling (SEC Form 4). Params: `{ ticker, limit?, filing_date*? }` +- `get_segmented_revenues` — Revenue by segment/geography. Params: `{ ticker, period, limit? }` + +#### SEC Filings +- `get_filings` — List filings metadata. Params: `{ ticker, filing_type?, limit? }` + - filing_type: "10-K", "10-Q", "8-K" +- `get_filing_items` — Read specific filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` + - item: array of section names (e.g. ["Item-1A", "Item-7"] for 10-K) + +### Research Workflow + +1. **Understand** what financial data is needed +2. **Get context** — start with `get_price_snapshot` and `get_company_facts` for orientation +3. **Gather data** — use the appropriate actions for the analysis +4. **Analyze** — interpret data with proper financial reasoning +5. **Present** — clear findings with data tables and key takeaways + +### Best Practices + +- Use `get_all_financial_statements` when you need multiple statement types (saves API calls) +- Use annual data for trend analysis, quarterly for recent performance, TTM for current state +- Cross-reference metrics: revenue growth vs cash flow growth, margins vs peers +- Always note the time period and currency when presenting financial data +- For SEC filing analysis: first `get_filings` to find relevant filings, then `get_filing_items` to read specific sections +- Common 10-K items: Item-1 (Business), Item-1A (Risk Factors), Item-7 (MD&A), Item-8 (Financial Statements) +- Common 10-Q items: Part-1,Item-1 (Financial Statements), Part-1,Item-2 (MD&A) + +### Example: Company Analysis + +For "Analyze Apple's financial health": + +``` +1. data(domain="finance", action="get_price_snapshot", params={ticker: "AAPL"}) +2. data(domain="finance", action="get_company_facts", params={ticker: "AAPL"}) +3. data(domain="finance", action="get_all_financial_statements", params={ticker: "AAPL", period: "annual", limit: 3}) +4. data(domain="finance", action="get_financial_metrics_snapshot", params={ticker: "AAPL"}) +5. data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL"}) +``` + +Then analyze trends, margins, growth rates, and present findings. From d80e97648c14f7f10fcdf645564ba68216fffab9 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 16:45:46 +0800 Subject: [PATCH 04/30] fix(core): read data tool API key from credentials.json5 Use credentialManager.getToolConfig("data") as the primary source for the Financial Datasets API key, with FINANCIAL_DATASETS_API_KEY env var as fallback. Also add data tool entry to the credentials template. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/credentials-cli.ts | 3 ++- .../core/src/agent/tools/data/finance/api.ts | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agent/credentials-cli.ts b/packages/core/src/agent/credentials-cli.ts index 9fae8bbd..d5fa9a13 100644 --- a/packages/core/src/agent/credentials-cli.ts +++ b/packages/core/src/agent/credentials-cli.ts @@ -44,7 +44,8 @@ function buildCoreTemplate(): string { }, tools: { // brave: { apiKey: "brv-..." }, - // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" } + // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" }, + // data: { apiKey: "your-financial-datasets-api-key" } } } `; diff --git a/packages/core/src/agent/tools/data/finance/api.ts b/packages/core/src/agent/tools/data/finance/api.ts index c775291b..e869f655 100644 --- a/packages/core/src/agent/tools/data/finance/api.ts +++ b/packages/core/src/agent/tools/data/finance/api.ts @@ -12,14 +12,19 @@ const BASE_URL = "https://api.financialdatasets.ai"; const TIMEOUT_MS = 30_000; function getApiKey(): string { - const key = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY"); - if (!key) { - throw new Error( - "FINANCIAL_DATASETS_API_KEY not configured. " + - "Set it in ~/.super-multica/skills.env.json5 under env, or as an environment variable.", - ); - } - return key; + // 1. credentials.json5 → tools.data.apiKey (preferred) + const toolConfig = credentialManager.getToolConfig("data"); + if (toolConfig?.apiKey) return toolConfig.apiKey; + + // 2. Fallback: env var (skills.env.json5 or process.env) + const envKey = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY"); + if (envKey) return envKey; + + throw new Error( + "Financial Datasets API key not configured. " + + 'Set it in ~/.super-multica/credentials.json5 under tools.data.apiKey, ' + + "or set FINANCIAL_DATASETS_API_KEY in ~/.super-multica/skills.env.json5.", + ); } /** From 5a3dc6c1e1325e0a50f2d4c399623056756291cc Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 16:54:42 +0800 Subject: [PATCH 05/30] feat(ui): add data tool display config in chat UI Add chart icon, smart subtitle (shows action + ticker, e.g. "price_snapshot AAPL"), and running state label for the data tool in the tool call UI component. Co-Authored-By: Claude Opus 4.6 --- packages/ui/src/components/tool-call-item.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index 87e665f6..db93c03e 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -12,6 +12,7 @@ import { GlobeIcon, DatabaseIcon, GitBranchIcon, + ChartBarLineIcon, ArrowRight01Icon, } from "@hugeicons/core-free-icons" import { cn, getTextContent } from "@multica/ui/lib/utils" @@ -39,6 +40,7 @@ const TOOL_DISPLAY: Record = memory_delete: { label: "MemoryDelete", icon: DatabaseIcon }, memory_list: { label: "MemoryList", icon: DatabaseIcon }, sessions_spawn: { label: "SpawnSession", icon: GitBranchIcon }, + data: { label: "Data", icon: ChartBarLineIcon }, } // --------------------------------------------------------------------------- @@ -73,6 +75,12 @@ function getSubtitle(toolName: string, args?: Record): string { return args.query ? String(args.query) : "" case "web_fetch": try { return new URL(String(args.url)).hostname } catch { return String(args.url ?? "") } + case "data": { + const action = String(args.action ?? "").replace(/^get_/, "") + const params = args.params as Record | undefined + const ticker = params?.ticker ? String(params.ticker).toUpperCase() : "" + return ticker ? `${action} ${ticker}` : action + } default: return "" } @@ -91,6 +99,7 @@ const RUNNING_LABELS: Record = { glob: "searching…", web_search: "searching…", web_fetch: "fetching…", + data: "fetching…", } /** Stats derived from tool result content */ From 4e5c99a97f762e01b8d9232f6045a4b44f29d469 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 16:59:16 +0800 Subject: [PATCH 06/30] feat(ui): show task context for sessions_spawn in chat UI Display the label (or truncated task) as subtitle for SpawnSession tool calls, so users can see what each spawned session is working on. Co-Authored-By: Claude Opus 4.6 --- packages/ui/src/components/tool-call-item.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index db93c03e..32888fae 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -81,6 +81,12 @@ function getSubtitle(toolName: string, args?: Record): string { const ticker = params?.ticker ? String(params.ticker).toUpperCase() : "" return ticker ? `${action} ${ticker}` : action } + case "sessions_spawn": { + const label = args.label ? String(args.label) : "" + if (label) return label.length > 60 ? label.slice(0, 57) + "…" : label + const task = String(args.task ?? "") + return task.length > 60 ? task.slice(0, 57) + "…" : task + } default: return "" } @@ -100,6 +106,7 @@ const RUNNING_LABELS: Record = { web_search: "searching…", web_fetch: "fetching…", data: "fetching…", + sessions_spawn: "spawning…", } /** Stats derived from tool result content */ From 45db13cbdd7474e7922e77c8bfd23fe9c93833f3 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:09:26 +0800 Subject: [PATCH 07/30] feat(subagent): add two-tier announce delivery with debounced queue Add announce-queue.ts for batching nearby subagent completions with debounce (1s) and collect mode. Implement two-tier delivery in announce.ts: queue when parent is busy, writeInternal when idle. All delivery uses writeInternal() to mark messages as internal, preventing announcement bubbles from appearing in the UI. Co-Authored-By: Claude Opus 4.6 --- .../src/agent/subagent/announce-queue.test.ts | 203 +++++++++++ .../core/src/agent/subagent/announce-queue.ts | 315 ++++++++++++++++++ packages/core/src/agent/subagent/announce.ts | 56 +++- 3 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/agent/subagent/announce-queue.test.ts create mode 100644 packages/core/src/agent/subagent/announce-queue.ts diff --git a/packages/core/src/agent/subagent/announce-queue.test.ts b/packages/core/src/agent/subagent/announce-queue.test.ts new file mode 100644 index 00000000..bff56972 --- /dev/null +++ b/packages/core/src/agent/subagent/announce-queue.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + enqueueAnnounce, + resetAnnounceQueuesForTests, + getAnnounceQueueDepth, + type AnnounceQueueItem, + type AnnounceQueueSettings, +} from "./announce-queue.js"; + +afterEach(() => { + resetAnnounceQueuesForTests(); +}); + +function makeItem(overrides?: Partial): AnnounceQueueItem { + return { + prompt: "test prompt", + summaryLine: "test summary", + enqueuedAt: Date.now(), + requesterSessionId: "session-1", + ...overrides, + }; +} + +const FAST_SETTINGS: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 20, + dropPolicy: "old", +}; + +describe("announce queue", () => { + it("enqueues an item and drains via send callback", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: FAST_SETTINGS, + send, + }); + + // Wait for async drain + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(1); + expect(sent[0]!.prompt).toBe("test prompt"); + }); + + it("batches items in collect mode", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + const collectSettings: AnnounceQueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 20, + dropPolicy: "old", + }; + + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 1" }), + settings: collectSettings, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 2" }), + settings: collectSettings, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 3" }), + settings: collectSettings, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Collect mode batches all into one send + expect(sent).toHaveLength(1); + expect(sent[0]!.prompt).toContain("prompt 1"); + expect(sent[0]!.prompt).toContain("prompt 2"); + expect(sent[0]!.prompt).toContain("prompt 3"); + expect(sent[0]!.prompt).toContain("3 queued announce(s)"); + }); + + it("sends items individually in followup mode", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt A" }), + settings: FAST_SETTINGS, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt B" }), + settings: FAST_SETTINGS, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(2); + expect(sent[0]!.prompt).toBe("prompt A"); + expect(sent[1]!.prompt).toBe("prompt B"); + }); + + it("respects cap with 'new' drop policy (rejects new items)", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { + // Slow send to keep items in queue + await new Promise((r) => setTimeout(r, 200)); + sent.push(item); + }; + + const cappedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 2, + dropPolicy: "new", + }; + + const r1 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "1" }), settings: cappedSettings, send }); + const r2 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "2" }), settings: cappedSettings, send }); + const r3 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "3" }), settings: cappedSettings, send }); + + expect(r1).toBe(true); + expect(r2).toBe(true); + expect(r3).toBe(false); // Rejected — cap reached + }); + + it("respects cap with 'old' drop policy (drops oldest)", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { + await new Promise((r) => setTimeout(r, 200)); + sent.push(item); + }; + + const cappedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 2, + dropPolicy: "old", + }; + + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "1" }), settings: cappedSettings, send }); + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "2" }), settings: cappedSettings, send }); + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "3" }), settings: cappedSettings, send }); + + // Queue should have items 2 and 3 (oldest was dropped) + expect(getAnnounceQueueDepth("test")).toBeLessThanOrEqual(2); + }); + + it("cleans up queue after drain completes", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: FAST_SETTINGS, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(1); + expect(getAnnounceQueueDepth("test")).toBe(0); + }); + + it("debounces before draining", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + const debouncedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 100, + cap: 20, + dropPolicy: "old", + }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: debouncedSettings, + send, + }); + + // Should not have sent yet (debounce) + await new Promise((r) => setTimeout(r, 30)); + expect(sent).toHaveLength(0); + + // Wait for debounce to complete + await new Promise((r) => setTimeout(r, 150)); + expect(sent).toHaveLength(1); + }); +}); diff --git a/packages/core/src/agent/subagent/announce-queue.ts b/packages/core/src/agent/subagent/announce-queue.ts new file mode 100644 index 00000000..8e88a346 --- /dev/null +++ b/packages/core/src/agent/subagent/announce-queue.ts @@ -0,0 +1,315 @@ +/** + * Announce queue for subagent result delivery. + * + * Handles queuing and batching of subagent announcements when the parent + * agent is busy. Supports debounce, cap, drop policy, and collect mode. + * + * Ported from OpenClaw (MIT license), adapted for Super Multica. + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type AnnounceQueueMode = + /** Try steer, no queue fallback */ + | "steer" + /** Try steer, fall back to queue */ + | "steer-backlog" + /** Queue and send items individually */ + | "followup" + /** Queue and batch all items into one combined prompt */ + | "collect"; + +export type AnnounceDropPolicy = + /** Drop oldest items when cap reached */ + | "old" + /** Drop newest items when cap reached */ + | "new" + /** Summarize dropped items */ + | "summarize"; + +export type AnnounceQueueItem = { + prompt: string; + summaryLine?: string; + enqueuedAt: number; + requesterSessionId: string; +}; + +export type AnnounceQueueSettings = { + mode: AnnounceQueueMode; + debounceMs?: number; + cap?: number; + dropPolicy?: AnnounceDropPolicy; +}; + +type AnnounceQueueState = { + items: AnnounceQueueItem[]; + draining: boolean; + lastEnqueuedAt: number; + mode: AnnounceQueueMode; + debounceMs: number; + cap: number; + dropPolicy: AnnounceDropPolicy; + droppedCount: number; + summaryLines: string[]; + send: (item: AnnounceQueueItem) => Promise; +}; + +// ============================================================================ +// Defaults +// ============================================================================ + +const DEFAULT_DEBOUNCE_MS = 1000; +const DEFAULT_CAP = 20; +const DEFAULT_DROP_POLICY: AnnounceDropPolicy = "summarize"; + +export const DEFAULT_ANNOUNCE_SETTINGS: AnnounceQueueSettings = { + mode: "steer-backlog", + debounceMs: DEFAULT_DEBOUNCE_MS, + cap: DEFAULT_CAP, + dropPolicy: DEFAULT_DROP_POLICY, +}; + +// ============================================================================ +// Module state +// ============================================================================ + +const ANNOUNCE_QUEUES = new Map(); + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Enqueue an announcement for delivery. Returns true if enqueued, + * false if dropped (cap + "new" drop policy). + */ +export function enqueueAnnounce(params: { + key: string; + item: AnnounceQueueItem; + settings: AnnounceQueueSettings; + send: (item: AnnounceQueueItem) => Promise; +}): boolean { + const queue = getOrCreateQueue(params.key, params.settings, params.send); + queue.lastEnqueuedAt = Date.now(); + + const shouldEnqueue = applyDropPolicy(queue, params.item); + if (!shouldEnqueue) { + if (queue.dropPolicy === "new") { + scheduleAnnounceDrain(params.key); + } + return false; + } + + queue.items.push(params.item); + scheduleAnnounceDrain(params.key); + return true; +} + +/** Reset all queues (for testing). */ +export function resetAnnounceQueuesForTests(): void { + ANNOUNCE_QUEUES.clear(); +} + +/** Get the current queue depth for a key (for testing/diagnostics). */ +export function getAnnounceQueueDepth(key: string): number { + return ANNOUNCE_QUEUES.get(key)?.items.length ?? 0; +} + +// ============================================================================ +// Queue management +// ============================================================================ + +function getOrCreateQueue( + key: string, + settings: AnnounceQueueSettings, + send: (item: AnnounceQueueItem) => Promise, +): AnnounceQueueState { + const existing = ANNOUNCE_QUEUES.get(key); + if (existing) { + existing.mode = settings.mode; + if (typeof settings.debounceMs === "number") { + existing.debounceMs = Math.max(0, settings.debounceMs); + } + if (typeof settings.cap === "number" && settings.cap > 0) { + existing.cap = Math.floor(settings.cap); + } + if (settings.dropPolicy) { + existing.dropPolicy = settings.dropPolicy; + } + existing.send = send; + return existing; + } + + const created: AnnounceQueueState = { + items: [], + draining: false, + lastEnqueuedAt: 0, + mode: settings.mode, + debounceMs: + typeof settings.debounceMs === "number" + ? Math.max(0, settings.debounceMs) + : DEFAULT_DEBOUNCE_MS, + cap: + typeof settings.cap === "number" && settings.cap > 0 + ? Math.floor(settings.cap) + : DEFAULT_CAP, + dropPolicy: settings.dropPolicy ?? DEFAULT_DROP_POLICY, + droppedCount: 0, + summaryLines: [], + send, + }; + ANNOUNCE_QUEUES.set(key, created); + return created; +} + +// ============================================================================ +// Drop policy +// ============================================================================ + +function applyDropPolicy( + queue: AnnounceQueueState, + item: AnnounceQueueItem, +): boolean { + if (queue.items.length < queue.cap) { + return true; + } + + switch (queue.dropPolicy) { + case "new": + // Reject the incoming item + return false; + + case "old": { + // Drop the oldest item to make room + const dropped = queue.items.shift(); + if (dropped) { + queue.droppedCount++; + const summary = dropped.summaryLine?.trim() || dropped.prompt.slice(0, 80); + queue.summaryLines.push(summary); + } + return true; + } + + case "summarize": { + // Drop the oldest item but keep a summary + const dropped = queue.items.shift(); + if (dropped) { + queue.droppedCount++; + const summary = dropped.summaryLine?.trim() || dropped.prompt.slice(0, 80); + queue.summaryLines.push(summary); + } + return true; + } + + default: + return true; + } +} + +// ============================================================================ +// Drain scheduling +// ============================================================================ + +function scheduleAnnounceDrain(key: string): void { + const queue = ANNOUNCE_QUEUES.get(key); + if (!queue || queue.draining) return; + + queue.draining = true; + void (async () => { + try { + while (queue.items.length > 0 || queue.droppedCount > 0) { + await waitForDebounce(queue); + + if (queue.mode === "collect") { + // Batch all items into one combined prompt + const items = queue.items.splice(0, queue.items.length); + const summary = buildDropSummary(queue); + const prompt = buildCollectPrompt(items, summary); + const last = items.at(-1); + if (!last) break; + await queue.send({ ...last, prompt }); + continue; + } + + // followup / steer-backlog: send items individually + const summary = buildDropSummary(queue); + if (summary) { + const next = queue.items.shift(); + if (!next) break; + await queue.send({ ...next, prompt: summary }); + continue; + } + + const next = queue.items.shift(); + if (!next) break; + await queue.send(next); + } + } catch (err) { + console.error(`[AnnounceQueue] Drain failed for ${key}: ${String(err)}`); + } finally { + queue.draining = false; + if (queue.items.length === 0 && queue.droppedCount === 0) { + ANNOUNCE_QUEUES.delete(key); + } else { + scheduleAnnounceDrain(key); + } + } + })(); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function waitForDebounce(queue: AnnounceQueueState): Promise { + const elapsed = Date.now() - queue.lastEnqueuedAt; + const remaining = Math.max(0, queue.debounceMs - elapsed); + if (remaining <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, remaining)); +} + +function buildDropSummary(queue: AnnounceQueueState): string | undefined { + if (queue.droppedCount === 0) return undefined; + + const parts: string[] = [ + `[${queue.droppedCount} earlier announce(s) were summarized due to queue backlog]`, + ]; + if (queue.summaryLines.length > 0) { + parts.push(""); + for (const line of queue.summaryLines) { + parts.push(`- ${line}`); + } + } + + // Reset counters + queue.droppedCount = 0; + queue.summaryLines = []; + + return parts.join("\n"); +} + +function buildCollectPrompt( + items: AnnounceQueueItem[], + dropSummary: string | undefined, +): string { + const parts: string[] = [ + `[${items.length} queued announce(s) while agent was busy]`, + "", + ]; + + for (let i = 0; i < items.length; i++) { + parts.push(`---`); + parts.push(`Queued #${i + 1}`); + parts.push(items[i]!.prompt); + parts.push(""); + } + + if (dropSummary) { + parts.push(dropSummary); + parts.push(""); + } + + return parts.join("\n"); +} diff --git a/packages/core/src/agent/subagent/announce.ts b/packages/core/src/agent/subagent/announce.ts index 54c92e75..3532d1af 100644 --- a/packages/core/src/agent/subagent/announce.ts +++ b/packages/core/src/agent/subagent/announce.ts @@ -16,6 +16,7 @@ import type { SubagentRunRecord, SubagentSystemPromptParams, } from "./types.js"; +import { enqueueAnnounce, DEFAULT_ANNOUNCE_SETTINGS } from "./announce-queue.js"; /** * Build the system prompt injected into a subagent session. @@ -275,7 +276,15 @@ export function formatCoalescedAnnouncementMessage( /** * Run the coalesced announcement flow for all completed runs of a requester. - * Formats a single combined message and delivers it to the parent agent. + * Uses two-tier delivery: + * 1. Queue — if parent is busy (running or has pending writes), queue for + * later drain via writeInternal (with debounce to batch nearby completions) + * 2. Direct — if parent is idle, send immediately via writeInternal + * + * All delivery uses writeInternal() which marks entries as `internal: true`, + * preventing announcement messages from showing as user bubbles in the UI. + * We avoid steer() (cancels unrelated tool calls) and followUp() (doesn't + * mark entries as internal, polluting the chat UI). */ export function runCoalescedAnnounceFlow( requesterSessionId: string, @@ -293,7 +302,28 @@ export function runCoalescedAnnounceFlow( return false; } - parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); + // Tier 1: BUSY — parent is running or has pending writes + // Queue the announcement for delivery via writeInternal() after the parent + // finishes its current work. We do NOT use steer() (cancels unrelated tool + // calls) or followUp() (doesn't mark entries as internal, polluting the UI). + if (parentAgent.isRunning || parentAgent.getPendingWrites() > 0) { + enqueueAnnounce({ + key: requesterSessionId, + item: { + prompt: message, + summaryLine: `${records.length} subagent(s) completed`, + enqueuedAt: Date.now(), + requesterSessionId, + }, + settings: DEFAULT_ANNOUNCE_SETTINGS, + send: async (item) => sendAnnounceDirect(requesterSessionId, item.prompt), + }); + console.log(`[SubagentAnnounce] Queued announcement for parent: ${requesterSessionId}`); + return true; + } + + // Tier 2: IDLE — parent is idle, send directly via writeInternal + sendAnnounceDirect(requesterSessionId, message); return true; } catch (err) { console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err); @@ -301,6 +331,26 @@ export function runCoalescedAnnounceFlow( } } +/** + * Send announcement directly to parent via writeInternal. + * Used as Tier 3 (idle) and as the queue drain callback. + */ +function sendAnnounceDirect(requesterSessionId: string, message: string): void { + try { + const hub = getHub(); + const parentAgent = hub.getAgent(requesterSessionId); + if (!parentAgent || parentAgent.closed) { + console.warn( + `[SubagentAnnounce] Parent agent not found or closed for direct send: ${requesterSessionId}`, + ); + return; + } + parentAgent.writeInternal(message, { forwardAssistant: false, persistResponse: true }); + } catch (err) { + console.error(`[SubagentAnnounce] Failed direct announce to parent:`, err); + } +} + /** * Run the full subagent announcement flow: * 1. Read child's last assistant reply @@ -341,7 +391,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean return false; } - parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); + parentAgent.writeInternal(message, { forwardAssistant: false, persistResponse: true }); return true; } catch (err) { console.error(`[SubagentAnnounce] Failed to announce to parent:`, err); From 299c947893e12a51a20fb1cafb22fbb431c83dcd Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:09:33 +0800 Subject: [PATCH 08/30] feat(agent): expose isRunning and lastRunError on Agent and AsyncAgent Add isRunning flag to Agent (runner.ts) for detecting active runs. Add lastRunError to AsyncAgent for propagating child run errors to the registry. Fix duplicate message emission in writeInternal by resetting forwardInternalAssistant before persistAssistantSummary. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/async-agent.test.ts | 12 +++++ packages/core/src/agent/async-agent.ts | 53 ++++++++++++++++++++- packages/core/src/agent/runner.ts | 43 ++++++++++++++++- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agent/async-agent.test.ts b/packages/core/src/agent/async-agent.test.ts index 26e7cc9d..e9700d03 100644 --- a/packages/core/src/agent/async-agent.test.ts +++ b/packages/core/src/agent/async-agent.test.ts @@ -272,6 +272,18 @@ describe("AsyncAgent internal flow", () => { agent.close(); }); + it("does not persist assistant summary when result text is a NO_REPLY variant", async () => { + runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY.", thinking: undefined, error: undefined }); + const agent = new AsyncAgent(); + + agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true }); + await agent.waitForIdle(); + + expect(persistAssistantSummaryMock).not.toHaveBeenCalled(); + + agent.close(); + }); + it("does not persist assistant summary when result text is empty", async () => { runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined }); const agent = new AsyncAgent(); diff --git a/packages/core/src/agent/async-agent.ts b/packages/core/src/agent/async-agent.ts index 30e1f94d..41c33778 100644 --- a/packages/core/src/agent/async-agent.ts +++ b/packages/core/src/agent/async-agent.ts @@ -5,6 +5,7 @@ import { Channel } from "./channel.js"; import type { AgentOptions, Message } from "./types.js"; import type { MulticaEvent } from "./events.js"; import { injectMessageTimestamp } from "./message-timestamp.js"; +import { isSilentReplyText } from "./tokens.js"; const devNull = { write: () => true } as unknown as NodeJS.WritableStream; @@ -31,6 +32,7 @@ export class AsyncAgent { private pendingWrites = 0; private closeCallbacks: Array<() => void> = []; private forwardInternalAssistant = false; + private _lastRunError: string | undefined; readonly sessionId: string; constructor(options?: AgentOptions) { @@ -64,13 +66,19 @@ export class AsyncAgent { this.queue = this.queue .then(async () => { - if (this._closed) return; + if (this._closed) { + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] write() skipped — agent closed`); + return; + } + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] run() starting for message: ${content.slice(0, 80)}`); const result = await this.agent.run(message, { displayPrompt: content }); + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] run() completed, error=${result.error ?? "none"}`); // Flush pending session writes so waitForIdle() callers // can safely read session data from disk. await this.agent.flushSession(); // Normal text is delivered via message_end event; only handle errors here if (result.error) { + this._lastRunError = result.error; console.error(`[AsyncAgent] Agent run error: ${result.error}`); this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); // Only emit agent_error for HTTP 401 from the LLM provider so the @@ -83,6 +91,7 @@ export class AsyncAgent { }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); + this._lastRunError = message; console.error(`[AsyncAgent] Agent run exception: ${message}`); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); // Only emit agent_error for HTTP 401 from the LLM provider so the @@ -120,8 +129,11 @@ export class AsyncAgent { // Internal run errors are for diagnostics only; do not leak to user stream. console.error(`[AsyncAgent] Internal run error: ${result.error}`); } + // Stop forwarding BEFORE persist to avoid double-emitting the same + // assistant message (once from runInternal streaming, once from appendMessage). + this.forwardInternalAssistant = prevForward; // Persist the LLM summary so it remains in parent context for future turns - if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") { + if (persistResponse && result.text?.trim() && !isSilentReplyText(result.text)) { this.agent.persistAssistantSummary(result.text.trim()); await this.agent.flushSession(); } @@ -164,6 +176,43 @@ export class AsyncAgent { return this.queue; } + /** Error message from the last run, if it failed. */ + get lastRunError(): string | undefined { + return this._lastRunError; + } + + /** Whether the agent is currently executing a run (normal or internal). */ + get isRunning(): boolean { + return this.agent.isRunning; + } + + /** Whether the underlying LLM is currently streaming a response. */ + get isStreaming(): boolean { + return this.agent.isStreaming; + } + + /** + * Steer the agent mid-run. Bypasses the serial queue and injects a message + * directly into the PiAgentCore steering queue. The message is delivered + * after the current tool execution completes, skipping remaining tool calls. + */ + steer(content: string): void { + this.agent.steer(content); + } + + /** + * Queue a follow-up message for after the current run finishes. + * Delivered only when the agent has no more tool calls or steering messages. + */ + followUp(content: string): void { + this.agent.followUp(content); + } + + /** Whether the underlying PiAgentCore has queued steer/followUp messages. */ + hasQueuedMessages(): boolean { + return this.agent.hasQueuedMessages(); + } + private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean { if (!this.agent.isInternalRun) return true; if (!this.forwardInternalAssistant) return false; diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index beaa4faf..b5ac016f 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -91,6 +91,7 @@ export class Agent { // Internal run state private _internalRun = false; + private _isRunning = false; private _runMutex: Promise = Promise.resolve(); private currentUserDisplayPrompt: string | undefined; @@ -304,8 +305,8 @@ export class Agent { const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); const profileDir = this.profile?.getProfileDir(); this.toolsOptions = mergedToolsConfig - ? { ...options, tools: mergedToolsConfig, profileDir } - : { ...options, profileDir }; + ? { ...options, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } + : { ...options, profileDir, provider: this.resolvedProvider }; const tools = resolveTools(this.toolsOptions); if (this.debug) { @@ -427,6 +428,7 @@ export class Agent { this.refreshAuthState(); this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; + this._isRunning = true; try { // Early validation: check API key before calling PiAgentCore.prompt(), @@ -489,6 +491,7 @@ export class Agent { : undefined; return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error }; } finally { + this._isRunning = false; this.currentUserDisplayPrompt = undefined; } } @@ -664,6 +667,40 @@ export class Agent { return this._internalRun; } + /** Whether a run (normal or internal) is currently executing inside _run(). */ + get isRunning(): boolean { + return this._isRunning; + } + + /** Whether the underlying PiAgentCore is currently streaming an LLM response. */ + get isStreaming(): boolean { + return this.agent.state.isStreaming; + } + + /** + * Queue a steering message to interrupt the agent mid-run. + * Delivered after current tool execution, skipping remaining tool calls. + * Safe to call from any context (does not require the run mutex). + */ + steer(content: string): void { + const msg: UserMessage = { role: "user", content, timestamp: Date.now() }; + this.agent.steer(msg); + } + + /** + * Queue a follow-up message for after the current run finishes. + * Delivered only when the agent has no more tool calls or steering messages. + */ + followUp(content: string): void { + const msg: UserMessage = { role: "user", content, timestamp: Date.now() }; + this.agent.followUp(msg); + } + + /** Whether the underlying PiAgentCore has queued steer/followUp messages. */ + hasQueuedMessages(): boolean { + return this.agent.hasQueuedMessages(); + } + /** * Persist a synthetic assistant message into both in-memory state and session JSONL. * Used after an internal run to keep the LLM summary visible in future turns @@ -895,6 +932,8 @@ export class Agent { // Update internal state this.resolvedProvider = providerId; + // Keep toolsOptions.provider in sync so sessions_spawn inherits the current provider + this.toolsOptions = { ...this.toolsOptions, provider: providerId }; // Update session metadata (save original providerId, not alias-resolved) this.session.saveMeta({ From 2e6d419c276d1d3d08693cf22b661c56446182ca Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:09:42 +0800 Subject: [PATCH 09/30] feat(subagent): deferred record cleanup and error propagation Replace immediate record deletion with archiveAtMs-based deferred cleanup (60min retention). This keeps records queryable via sessions_list after completion. Add sweeper to clean expired records. Check childAgent.lastRunError after waitForIdle to detect failed runs that resolve the promise without throwing. Co-Authored-By: Claude Opus 4.6 --- .../core/src/agent/subagent/registry.test.ts | 19 +++-- packages/core/src/agent/subagent/registry.ts | 72 +++++++++++-------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/packages/core/src/agent/subagent/registry.test.ts b/packages/core/src/agent/subagent/registry.test.ts index 5d5eacc3..14414e95 100644 --- a/packages/core/src/agent/subagent/registry.test.ts +++ b/packages/core/src/agent/subagent/registry.test.ts @@ -267,7 +267,7 @@ describe("subagent registry — coalescing", () => { }); describe("subagent registry — post-announce cleanup", () => { - it("removes runs from registry after successful announcement", async () => { + it("keeps runs in registry after successful announcement with archiveAtMs", async () => { // Mock runCoalescedAnnounceFlow to succeed const announceModule = await import("./announce.js"); const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); @@ -288,11 +288,20 @@ describe("subagent registry — post-announce cleanup", () => { await flushQueue(); - // Both runs should have been announced and removed from registry + // Both runs should have been announced but kept in registry with archiveAtMs expect(spy).toHaveBeenCalled(); - expect(getSubagentRun("run-a")).toBeUndefined(); - expect(getSubagentRun("run-b")).toBeUndefined(); - expect(listSubagentRuns("parent-1")).toHaveLength(0); + + const runA = getSubagentRun("run-a"); + const runB = getSubagentRun("run-b"); + expect(runA).toBeDefined(); + expect(runB).toBeDefined(); + expect(runA!.announced).toBe(true); + expect(runB!.announced).toBe(true); + expect(runA!.archiveAtMs).toBeGreaterThan(Date.now()); + expect(runB!.archiveAtMs).toBeGreaterThan(Date.now()); + + // Records are still queryable + expect(listSubagentRuns("parent-1")).toHaveLength(2); spy.mockRestore(); }); diff --git a/packages/core/src/agent/subagent/registry.ts b/packages/core/src/agent/subagent/registry.ts index f40a72c0..665eb3fc 100644 --- a/packages/core/src/agent/subagent/registry.ts +++ b/packages/core/src/agent/subagent/registry.ts @@ -121,7 +121,9 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent // Enqueue in the subagent lane — the start callback and watchChildAgent // only execute once a concurrency slot is available. void enqueueInLane(SubagentLane.Subagent, async () => { + console.log(`[SubagentRegistry] Lane slot acquired for ${runId}, calling start()`); start?.(); + console.log(`[SubagentRegistry] start() returned, entering watchChildAgent`); return watchChildAgent(record, timeoutSeconds); }); @@ -248,12 +250,26 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): Pr // Wait for the child agent's task queue to drain (task completion), // then trigger announce flow. Uses waitForIdle() instead of consuming // the stream (which would conflict with Hub.consumeAgent). + console.log(`[SubagentRegistry] waitForIdle() called for child ${childSessionId}, pendingWrites=${childAgent.getPendingWrites()}`); childAgent.waitForIdle().then( - () => cleanup({ status: "ok" }), - (err) => cleanup({ - status: "error", - error: err instanceof Error ? err.message : String(err), - }), + () => { + const runtime = Date.now() - (record.startedAt ?? 0); + const runError = childAgent.lastRunError; + if (runError) { + console.log(`[SubagentRegistry] waitForIdle() resolved for child ${childSessionId} with error (runtime: ${runtime}ms): ${runError}`); + cleanup({ status: "error", error: runError }); + } else { + console.log(`[SubagentRegistry] waitForIdle() resolved OK for child ${childSessionId} (runtime: ${runtime}ms)`); + cleanup({ status: "ok" }); + } + }, + (err) => { + console.error(`[SubagentRegistry] waitForIdle() rejected for child ${childSessionId}:`, err); + cleanup({ + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + }, ); // Also handle explicit close (e.g., timeout kill, Hub shutdown) @@ -280,43 +296,43 @@ function captureFindings(record: SubagentRunRecord): void { } /** - * Phase 2: Check if all unannounced runs for this requester have completed. - * If so, send a single coalesced announcement to the parent. + * Phase 2: Announce completed-but-unannounced runs immediately. + * + * Does NOT wait for all runs to finish — each completed run is announced + * as soon as its findings are captured. The three-tier delivery in + * announce.ts (steer → queue → direct) handles batching via the + * announce-queue debounce/collect mechanism when multiple runs complete + * close together. */ function checkAndAnnounce(requesterSessionId: string): void { const allRuns = listSubagentRuns(requesterSessionId); - // Only consider unannounced runs - const pending = allRuns.filter(r => !r.announced); - if (pending.length === 0) return; + // Only consider unannounced runs that are done with findings captured + const ready = allRuns.filter( + r => !r.announced && r.endedAt !== undefined && r.findingsCaptured, + ); + if (ready.length === 0) return; - // Are all unannounced runs done? - const allDone = pending.every(r => r.endedAt !== undefined); - if (!allDone) return; - - // Have all had findings captured? - const allCaptured = pending.every(r => r.findingsCaptured); - if (!allCaptured) return; - - // All done — send coalesced announcement - const announced = runCoalescedAnnounceFlow(requesterSessionId, pending); + // Announce all ready runs + const announced = runCoalescedAnnounceFlow(requesterSessionId, ready); if (announced) { - for (const r of pending) { + for (const r of ready) { r.announced = true; r.cleanupHandled = true; - // Remove from registry immediately — findings already delivered to parent - subagentRuns.delete(r.runId); + // Keep records for querying via sessions_list; let sweeper archive later + r.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS; } persist(); - if (subagentRuns.size === 0) { - stopSweeper(); - } } else { + // Allow retry — mark cleanupHandled false so initSubagentRegistry() retries + for (const r of ready) { + r.cleanupHandled = false; + } + persist(); console.warn( - `[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`, + `[SubagentRegistry] Announce failed for requester ${requesterSessionId}`, ); - // Leave announced=false so initSubagentRegistry() can retry on restart } } From 121b644df49518a7485c378293acee12a5c3b6cb Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:09:49 +0800 Subject: [PATCH 10/30] fix(subagent): inherit parent provider in spawned subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass parent's resolvedProvider through the tool chain (tools.ts → sessions-spawn.ts → hub.createSubagent) so subagents use the same LLM provider as the parent. Previously subagents fell back to the hardcoded default provider, causing API key errors. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/tools.ts | 4 ++++ packages/core/src/agent/tools/sessions-spawn.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index f3908d73..5c203412 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -26,6 +26,8 @@ export interface CreateToolsOptions { isSubagent?: boolean | undefined; /** Session ID of the agent (passed to sessions_spawn tool) */ sessionId?: string | undefined; + /** Resolved provider ID of the parent agent (passed to sessions_spawn for subagent inheritance) */ + provider?: string | undefined; /** Callback invoked when exec tool needs approval before running a command */ onExecApprovalNeeded?: ExecApprovalCallback | undefined; } @@ -131,6 +133,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const sessionsSpawnTool = createSessionsSpawnTool({ isSubagent: isSubagent ?? false, ...(sessionId !== undefined ? { sessionId } : {}), + ...(opts.provider !== undefined ? { provider: opts.provider } : {}), }); tools.push(sessionsSpawnTool as AgentTool); @@ -165,6 +168,7 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool[] { profileDir: options.profileDir, isSubagent: options.isSubagent, sessionId: options.sessionId, + provider: options.provider, onExecApprovalNeeded: options.onExecApprovalNeeded, }); diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 35c0017b..7c26de07 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -57,6 +57,8 @@ export interface CreateSessionsSpawnToolOptions { isSubagent?: boolean; /** Session ID of the current (requester) agent */ sessionId?: string; + /** Resolved provider ID of the parent agent (inherited by subagents) */ + provider?: string; } export function createSessionsSpawnTool( @@ -67,7 +69,9 @@ export function createSessionsSpawnTool( label: "Spawn Subagent", description: "Spawn a background subagent to handle a specific task. The subagent runs in an isolated session with its own tool set. " + - "When it completes, its findings are announced back to you automatically. " + + "When it completes, its findings are delivered directly into your context automatically — you do NOT need to poll or check. " + + "IMPORTANT: After spawning subagents, continue with any other immediate tasks you have, or simply finish your turn and wait. " + + "Do NOT call sessions_list to check on subagents you just spawned — results take time and will arrive on their own. " + "Use this for parallelizable work, long-running analysis, or tasks that benefit from isolation.", parameters: SessionsSpawnSchema, execute: async (_toolCallId, args) => { @@ -107,6 +111,7 @@ export function createSessionsSpawnTool( const childAgent = hub.createSubagent(childSessionId, { systemPrompt, model, + provider: options.provider, }); // Register the run for lifecycle tracking. @@ -127,7 +132,7 @@ export function createSessionsSpawnTool( content: [ { type: "text", - text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. You will receive its findings when it completes.`, + text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. Its findings will be delivered directly into your context when it completes — do NOT poll or call sessions_list for it. Continue with other tasks or finish your turn.`, }, ], details: { From 716e69ac39326a8753f6882d9970c2896484c978 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:09:56 +0800 Subject: [PATCH 11/30] feat(subagent): add anti-polling guards to sessions_list When subagents are still running, sessions_list now returns an explicit instruction telling the LLM not to poll again and wait for automatic result delivery. Normalizes status display to uppercase ([RUNNING], [OK], [ERROR]). Co-Authored-By: Claude Opus 4.6 --- .../src/agent/tools/sessions-list.test.ts | 6 +-- .../core/src/agent/tools/sessions-list.ts | 49 +++++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agent/tools/sessions-list.test.ts b/packages/core/src/agent/tools/sessions-list.test.ts index a0bf6c1a..989493de 100644 --- a/packages/core/src/agent/tools/sessions-list.test.ts +++ b/packages/core/src/agent/tools/sessions-list.test.ts @@ -73,9 +73,9 @@ describe("sessions_list tool", () => { const text = result.content[0]!; expect(text.type).toBe("text"); expect((text as { text: string }).text).toContain("3 total"); - expect((text as { text: string }).text).toContain("[running]"); - expect((text as { text: string }).text).toContain("[ok]"); - expect((text as { text: string }).text).toContain("[error]"); + expect((text as { text: string }).text).toContain("[RUNNING]"); + expect((text as { text: string }).text).toContain("[OK]"); + expect((text as { text: string }).text).toContain("[ERROR]"); expect((text as { text: string }).text).toContain("Code Review"); expect((text as { text: string }).text).toContain("Test Analysis"); expect((text as { text: string }).text).toContain("Lint Check"); diff --git a/packages/core/src/agent/tools/sessions-list.ts b/packages/core/src/agent/tools/sessions-list.ts index 19a0809e..988bfa1c 100644 --- a/packages/core/src/agent/tools/sessions-list.ts +++ b/packages/core/src/agent/tools/sessions-list.ts @@ -127,7 +127,9 @@ export function createSessionsListTool( label: "List Subagent Runs", description: "List all subagent runs spawned by this session and their current status. " + - "Optionally pass a runId to get detailed information about a specific run.", + "Optionally pass a runId to get detailed information about a specific run. " + + "NOTE: Do NOT call this immediately after spawning subagents — results arrive automatically in your context when subagents complete. " + + "Only use this if a long time has passed or the user explicitly asks about subagent status.", parameters: SessionsListSchema, execute: async (_toolCallId, args) => { const { runId } = args as SessionsListArgs; @@ -173,13 +175,52 @@ export function createSessionsListTool( }; } - const lines = [`Subagent runs for this session: ${runs.length} total`, ""]; + const someRunning = runs.some((r) => !r.endedAt); + + // Build status lines for each run + const statusLines: string[] = []; for (let i = 0; i < runs.length; i++) { - lines.push(formatRunSummary(runs[i]!, i, now)); + const r = runs[i]!; + const displayName = r.label || r.task.slice(0, 60); + const status = resolveStatus(r); + if (status === "running") { + const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned"; + statusLines.push(` ${i + 1}. [RUNNING] "${displayName}" (${elapsed})`); + } else { + const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : ""; + const findings = r.findingsCaptured + ? (r.findings ? r.findings.slice(0, 200) + (r.findings.length > 200 ? "…" : "") : "(no output)") + : "(findings not yet captured)"; + statusLines.push(` ${i + 1}. [${status.toUpperCase()}] "${displayName}" (${elapsed})\n Findings: ${findings}`); + } } + const header = `Subagent runs for this session: ${runs.length} total`; + const body = statusLines.join("\n"); + + // If any subagents are still running, return status with wait instruction. + // We do NOT use steer() here — steer would cancel unrelated tool calls + // that the LLM may be processing in the same batch. + if (someRunning) { + const runningCount = runs.filter((r) => !r.endedAt).length; + return { + content: [ + { + type: "text", + text: + header + "\n" + body + "\n\n" + + `STATUS: ${runningCount} subagent(s) still running. This is normal — they need time to complete.\n` + + "ACTION REQUIRED: Do NOT call sessions_list again. Results will be delivered into your context automatically when they finish.\n" + + "Do NOT attempt to do this work yourself — the subagents are handling it.", + }, + ], + details: { runs: runs.map(toResultRun) }, + }; + } + + // All completed — normal response return { - content: [{ type: "text", text: lines.join("\n") }], + content: [{ type: "text", text: header + "\n" + body }], details: { runs: runs.map(toResultRun) }, }; }, From 58ec56234cb6d6d9f18c2e06a3fc6b8864be5bc1 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:10:05 +0800 Subject: [PATCH 12/30] feat(subagent): improve system prompts for error reporting and timeout Add error reporting rule to subagent system prompt: subagents must explicitly report tool failures and missing credentials in their final message. Add timeout guidelines to parent system prompt with recommended values by task complexity (10-30 min). Co-Authored-By: Claude Opus 4.6 --- .../core/src/agent/system-prompt/sections.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index f0db6e33..4d2bf1cd 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -260,9 +260,19 @@ export function buildConditionalToolSections( lines.push( "## Sub-Agents", "If a task is complex or long-running, spawn a sub-agent. It will do the work and report back when done.", - "You can check on running sub-agents at any time.", + "IMPORTANT: After spawning sub-agents, do NOT immediately check on them with sessions_list. " + + "Results are delivered directly into your context automatically when the sub-agent finishes. " + + "Continue with other tasks or finish your turn and wait for the results to arrive.", + "You may use sessions_list to check on sub-agents only if a long time has passed or the user explicitly asks about their status.", "Sub-agents cannot spawn nested sub-agents.", "", + "### Timeout Guidelines", + "Set timeoutSeconds generously — a sub-agent that times out loses all its work.", + "- Simple tasks (search, read, summarize): 600 (10 min, the default)", + "- Moderate tasks (multi-step research, file downloads + analysis): 900–1200 (15–20 min)", + "- Complex tasks (code generation, PDF creation, multi-file operations): 1200–1800 (20–30 min)", + "When in doubt, use a longer timeout. It is always better to wait longer than to lose completed work.", + "", ); } @@ -364,6 +374,10 @@ export function buildSubagentSection( "## Subagent Rules", "- Stay focused on the assigned task below.", "- Complete the task thoroughly and report your findings.", + "- If you encounter errors (missing API keys, permission denied, tool failures, etc.), " + + "you MUST explicitly report them in your final message. " + + "State exactly what failed and what is needed to fix it — " + + "the parent agent relies on your final message to understand what happened.", "- Do NOT initiate side actions unrelated to the task.", "- Do NOT attempt to communicate with the user directly.", "- Do NOT spawn nested subagents.", From d01fcffe32d68391d76032b04faaf7ed3156b25d Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:10:12 +0800 Subject: [PATCH 13/30] docs(subagent): add architecture flowchart and README Document the full subagent lifecycle: spawn, concurrency queue, execution, completion handling, two-tier announcement delivery, and record archival. Include provider inheritance chain and error propagation diagrams. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/subagent/README.md | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 packages/core/src/agent/subagent/README.md diff --git a/packages/core/src/agent/subagent/README.md b/packages/core/src/agent/subagent/README.md new file mode 100644 index 00000000..e007eab1 --- /dev/null +++ b/packages/core/src/agent/subagent/README.md @@ -0,0 +1,172 @@ +# Subagent System + +The subagent system allows a parent agent to spawn isolated child agents that run tasks in parallel and report results back automatically. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Parent Agent (runner.ts) │ +│ │ +│ tools: sessions_spawn, sessions_list │ +│ state: resolvedProvider, toolsOptions │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + │ sessions_spawn(task, label, timeoutSeconds) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Spawn Flow (sessions-spawn.ts) │ +│ │ +│ 1. Build subagent system prompt (announce.ts) │ +│ 2. hub.createSubagent(childSessionId, { provider, model }) │ +│ 3. registerSubagentRun({ start: () => childAgent.write(task) }) │ +│ 4. Return { status: "accepted", runId, childSessionId } │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Concurrency Queue (command-queue.ts) │ +│ │ +│ Lane: "subagent" — max 10 concurrent (configurable) │ +│ Queued runs wait for a slot before start() is called │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ slot acquired + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Child Agent Execution │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ AsyncAgent (async-agent.ts) │ │ +│ │ - Isolated session with restricted tools (isSubagent=true) │ │ +│ │ - Inherits parent's LLM provider │ │ +│ │ - System prompt: task focus + error reporting rules │ │ +│ │ - Tracks lastRunError for error propagation │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ watchChildAgent (registry.ts) │ │ +│ │ - Sets startedAt, starts timeout timer │ │ +│ │ - waitForIdle() — waits for child's task queue to drain │ │ +│ │ - onClose() — handles explicit close (timeout kill, etc.) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + │ child completes / errors / times out + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Completion Handling (registry.ts) │ +│ │ +│ handleRunCompletion(record) │ +│ │ │ +│ ├─ Phase 1: captureFindings() │ +│ │ - Read last assistant reply from child session JSONL │ +│ │ - Falls back to last toolResult if no assistant text │ +│ │ - Persists findings to record before session deletion │ +│ │ │ +│ ├─ Session Cleanup │ +│ │ - cleanup="delete": rm child session dir + hub.closeAgent() │ +│ │ - cleanup="keep": preserve for audit │ +│ │ │ +│ └─ Phase 2: checkAndAnnounce(requesterSessionId) │ +│ - Finds all unannounced, completed runs with findings │ +│ - Calls runCoalescedAnnounceFlow() │ +│ - Marks records: announced=true, archiveAtMs=now+60min │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Announcement Delivery (announce.ts) │ +│ │ +│ runCoalescedAnnounceFlow(requesterSessionId, records) │ +│ │ │ +│ ├─ Format message: formatCoalescedAnnouncementMessage() │ +│ │ - Single record: task name, status, findings, stats │ +│ │ - Multiple records: combined report with all findings │ +│ │ │ +│ ├─ Two-tier delivery: │ +│ │ │ +│ │ Tier 1: BUSY (parent running or has pending writes) │ +│ │ └─ enqueueAnnounce() → announce-queue.ts │ +│ │ - Debounce 1s to batch nearby completions │ +│ │ - Drain via writeInternal() when parent finishes │ +│ │ │ +│ │ Tier 2: IDLE (parent not running) │ +│ │ └─ sendAnnounceDirect() │ +│ │ - writeInternal(msg, { forwardAssistant, persistResponse })│ +│ │ │ +│ └─ All delivery uses writeInternal() (marks as internal: true) │ +│ → Prevents announcement from showing as user bubble in UI │ +│ → LLM processes findings and responds naturally to user │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Record Lifecycle (registry.ts) │ +│ │ +│ created → startedAt → endedAt → findingsCaptured → announced │ +│ │ +│ After announcement: │ +│ - Record kept with archiveAtMs = now + 60 min │ +│ - sessions_list can still query records during this window │ +│ - Sweeper runs every 60s, removes expired records │ +│ - When all records removed, sweeper stops │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `sessions-spawn.ts` | Tool: spawns a child agent with task, label, timeout, provider | +| `sessions-list.ts` | Tool: lists subagent runs and their status | +| `registry.ts` | Lifecycle management: register, watch, capture, announce, archive | +| `announce.ts` | System prompt builder, findings reader, message formatter, delivery | +| `announce-queue.ts` | Debounced queue for batching announcements when parent is busy | +| `command-queue.ts` | Concurrency limiter for subagent lane slots | +| `lanes.ts` | Lane config: max concurrency (10), default timeout (600s) | +| `types.ts` | Shared types: SubagentRunRecord, SubagentRunOutcome, etc. | +| `registry-store.ts` | Persistence: save/load runs to disk for crash recovery | + +## Provider Inheritance + +Subagents inherit the parent's resolved LLM provider: + +``` +runner.ts (resolvedProvider) + → toolsOptions.provider + → tools.ts (CreateToolsOptions.provider) + → sessions-spawn.ts (options.provider) + → hub.createSubagent({ provider }) +``` + +When the user switches providers via UI (`setProvider()`), `toolsOptions.provider` is updated in sync so future spawns use the new provider. + +## Error Propagation + +``` +Child tool error (e.g., API 401) + → Subagent LLM sees error, includes in final message (system prompt rule) + → captureFindings() reads final message + → Announcement includes error in findings + → Parent LLM sees error and can inform user + +Child run error (e.g., missing API key for provider) + → AsyncAgent._lastRunError set + → registry.ts checks childAgent.lastRunError after waitForIdle() + → outcome = { status: "error", error: "No API key configured..." } + → Announcement: "task failed: No API key configured..." +``` + +## Timeout Behavior + +Default: 600s (10 min). System prompt guides the parent LLM: +- Simple tasks: 600s (default) +- Moderate tasks: 900-1200s (15-20 min) +- Complex tasks: 1200-1800s (20-30 min) + +On timeout: +1. Timeout timer fires in `watchChildAgent()` +2. `cleanup({ status: "timeout" })` is called +3. Child agent is closed via `hub.closeAgent()` +4. Findings are captured from whatever the child wrote so far +5. Announcement reports "timed out" with partial findings From de928cfe2be4bf6788911feabc9d7689039d3df7 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:16:22 +0800 Subject: [PATCH 14/30] fix(agent): add NO_REPLY detection utility for filtering silent replies Extract SILENT_REPLY_TOKEN and isSilentReplyText() into a shared module. Detects NO_REPLY at the start or end of text (with optional whitespace/punctuation) to filter out silent announcement responses that should not be forwarded to the user. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/tokens.test.ts | 39 ++++++++++++++++++++++++++ packages/core/src/agent/tokens.ts | 17 +++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/core/src/agent/tokens.test.ts create mode 100644 packages/core/src/agent/tokens.ts diff --git a/packages/core/src/agent/tokens.test.ts b/packages/core/src/agent/tokens.test.ts new file mode 100644 index 00000000..d15f1d4f --- /dev/null +++ b/packages/core/src/agent/tokens.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "./tokens.js"; + +describe("isSilentReplyText", () => { + it("detects exact NO_REPLY", () => { + expect(isSilentReplyText("NO_REPLY")).toBe(true); + }); + + it("detects NO_REPLY with surrounding whitespace", () => { + expect(isSilentReplyText(" NO_REPLY ")).toBe(true); + expect(isSilentReplyText("\nNO_REPLY\n")).toBe(true); + }); + + it("detects NO_REPLY with trailing punctuation", () => { + expect(isSilentReplyText("NO_REPLY.")).toBe(true); + expect(isSilentReplyText("NO_REPLY.\n")).toBe(true); + }); + + it("detects NO_REPLY at end of text", () => { + expect(isSilentReplyText("I have nothing to report. NO_REPLY")).toBe(true); + }); + + it("returns false for undefined/empty", () => { + expect(isSilentReplyText(undefined)).toBe(false); + expect(isSilentReplyText("")).toBe(false); + }); + + it("returns false for normal text", () => { + expect(isSilentReplyText("Here are the findings")).toBe(false); + }); + + it("returns false for NO_REPLY embedded in a word", () => { + expect(isSilentReplyText("DONO_REPLYX")).toBe(false); + }); + + it("exports SILENT_REPLY_TOKEN as NO_REPLY", () => { + expect(SILENT_REPLY_TOKEN).toBe("NO_REPLY"); + }); +}); diff --git a/packages/core/src/agent/tokens.ts b/packages/core/src/agent/tokens.ts new file mode 100644 index 00000000..2ec4136f --- /dev/null +++ b/packages/core/src/agent/tokens.ts @@ -0,0 +1,17 @@ +export const SILENT_REPLY_TOKEN = "NO_REPLY"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function isSilentReplyText( + text: string | undefined, + token: string = SILENT_REPLY_TOKEN, +): boolean { + if (!text) return false; + const escaped = escapeRegExp(token); + const prefix = new RegExp(`^\\s*${escaped}(?=$|\\W)`); + if (prefix.test(text)) return true; + const suffix = new RegExp(`\\b${escaped}\\b\\W*$`); + return suffix.test(text); +} From d7a02182ab032f90005b338f272e968694e01f36 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 17:40:00 +0800 Subject: [PATCH 15/30] feat(subagent): add announce:"silent" mode for deferred coalesced announcements Add an `announce` parameter to sessions_spawn that controls when findings are delivered to the parent agent: - "immediate" (default): announce per-completion (existing behavior) - "silent": defer until ALL silent runs from the same requester complete, then deliver ONE coalesced announcement with all findings This enables workflows like "spawn 10 parallel subagents to collect data, then summarize everything at once" without intermediate results. Changes: - types.ts: add `announce` field to SubagentRunRecord & RegisterSubagentRunParams - sessions-spawn.ts: add `announce` parameter to tool schema - registry.ts: split checkAndAnnounce into immediate/silent groups, extract announceGroup helper, use count-match guard for silent readiness - sections.ts: add announce mode guidance to system prompt - registry.test.ts: add silent mode tests (field storage, group isolation) Co-Authored-By: Claude Opus 4.6 --- .../core/src/agent/subagent/registry.test.ts | 97 +++++++++++++++++++ packages/core/src/agent/subagent/registry.ts | 44 ++++++--- packages/core/src/agent/subagent/types.ts | 5 + .../core/src/agent/system-prompt/sections.ts | 5 + .../core/src/agent/tools/sessions-spawn.ts | 13 ++- 5 files changed, 149 insertions(+), 15 deletions(-) diff --git a/packages/core/src/agent/subagent/registry.test.ts b/packages/core/src/agent/subagent/registry.test.ts index 14414e95..e1886dac 100644 --- a/packages/core/src/agent/subagent/registry.test.ts +++ b/packages/core/src/agent/subagent/registry.test.ts @@ -266,6 +266,103 @@ describe("subagent registry — coalescing", () => { }); }); +describe("subagent registry — silent announce mode", () => { + // Note: In tests (no Hub), watchChildAgent completes synchronously within + // registerSubagentRun(), so each run's lifecycle finishes before the next + // registration call. Multi-run coalescing requires async child agents and + // is validated in integration tests. + + it("stores announce field on the record", () => { + const record = registerSubagentRun({ + runId: "run-ann", + childSessionId: "child-ann", + requesterSessionId: "parent-1", + task: "Task", + announce: "silent", + }); + expect(record.announce).toBe("silent"); + }); + + it("defaults announce to undefined (immediate behavior)", () => { + const record = registerSubagentRun({ + runId: "run-def", + childSessionId: "child-def", + requesterSessionId: "parent-1", + task: "Task", + }); + expect(record.announce).toBeUndefined(); + }); + + it("silent runs are announced via runCoalescedAnnounceFlow", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent A", + announce: "silent", + }); + + await flushQueue(); + + // Silent run announced (via runCoalescedAnnounceFlow mock) + const silentCalls = spy.mock.calls.filter( + ([reqId, records]) => + reqId === "parent-1" && + records.some((r: { announce?: string }) => r.announce === "silent"), + ); + expect(silentCalls.length).toBeGreaterThanOrEqual(1); + + const runS1 = getSubagentRun("run-s1"); + expect(runS1?.announced).toBe(true); + expect(runS1?.announce).toBe("silent"); + + spy.mockRestore(); + }); + + it("immediate and silent runs are never mixed in the same announce call", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + // Register immediate run, then silent run + registerSubagentRun({ + runId: "run-imm", + childSessionId: "child-imm", + requesterSessionId: "parent-1", + task: "Immediate task", + }); + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent task", + announce: "silent", + }); + + await flushQueue(); + + const calls = spy.mock.calls.filter( + ([reqId]) => reqId === "parent-1", + ); + + // Immediate and silent should never be in the same announce call + const mixedCalls = calls.filter(([, records]) => { + const hasImm = records.some((r: { announce?: string }) => r.announce !== "silent"); + const hasSilent = records.some((r: { announce?: string }) => r.announce === "silent"); + return hasImm && hasSilent; + }); + expect(mixedCalls).toHaveLength(0); + + // Both should be announced (in separate calls) + expect(getSubagentRun("run-imm")?.announced).toBe(true); + expect(getSubagentRun("run-s1")?.announced).toBe(true); + + spy.mockRestore(); + }); +}); + describe("subagent registry — post-announce cleanup", () => { it("keeps runs in registry after successful announcement with archiveAtMs", async () => { // Mock runCoalescedAnnounceFlow to succeed diff --git a/packages/core/src/agent/subagent/registry.ts b/packages/core/src/agent/subagent/registry.ts index 665eb3fc..c19a5529 100644 --- a/packages/core/src/agent/subagent/registry.ts +++ b/packages/core/src/agent/subagent/registry.ts @@ -101,6 +101,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent label, cleanup = "delete", timeoutSeconds, + announce, start, } = params; @@ -111,6 +112,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent task, label, cleanup, + announce, createdAt: Date.now(), }; @@ -296,28 +298,42 @@ function captureFindings(record: SubagentRunRecord): void { } /** - * Phase 2: Announce completed-but-unannounced runs immediately. + * Phase 2: Announce completed-but-unannounced runs. * - * Does NOT wait for all runs to finish — each completed run is announced - * as soon as its findings are captured. The three-tier delivery in - * announce.ts (steer → queue → direct) handles batching via the - * announce-queue debounce/collect mechanism when multiple runs complete - * close together. + * Runs with announce="silent" are held back until ALL silent runs from the + * same requester have completed. All other runs (immediate / undefined) are + * announced per-completion as before. */ function checkAndAnnounce(requesterSessionId: string): void { const allRuns = listSubagentRuns(requesterSessionId); - // Only consider unannounced runs that are done with findings captured - const ready = allRuns.filter( - r => !r.announced && r.endedAt !== undefined && r.findingsCaptured, + // ── Immediate runs: announce per-completion (default behavior) ── + const immediateReady = allRuns.filter( + r => !r.announced && r.endedAt !== undefined && r.findingsCaptured && r.announce !== "silent", ); - if (ready.length === 0) return; + if (immediateReady.length > 0) { + announceGroup(requesterSessionId, immediateReady); + } - // Announce all ready runs - const announced = runCoalescedAnnounceFlow(requesterSessionId, ready); + // ── Silent runs: announce only when ALL silent runs are done ── + const silentRuns = allRuns.filter(r => r.announce === "silent"); + const unannouncedSilent = silentRuns.filter(r => !r.announced); + const silentReady = unannouncedSilent.filter( + r => r.endedAt !== undefined && r.findingsCaptured, + ); + + // All unannounced silent runs must be ready (ended + findings captured) + if (silentReady.length > 0 && silentReady.length === unannouncedSilent.length) { + announceGroup(requesterSessionId, silentReady); + } +} + +/** Announce a group of runs and mark them as announced. */ +function announceGroup(requesterSessionId: string, runs: SubagentRunRecord[]): void { + const announced = runCoalescedAnnounceFlow(requesterSessionId, runs); if (announced) { - for (const r of ready) { + for (const r of runs) { r.announced = true; r.cleanupHandled = true; // Keep records for querying via sessions_list; let sweeper archive later @@ -326,7 +342,7 @@ function checkAndAnnounce(requesterSessionId: string): void { persist(); } else { // Allow retry — mark cleanupHandled false so initSubagentRegistry() retries - for (const r of ready) { + for (const r of runs) { r.cleanupHandled = false; } persist(); diff --git a/packages/core/src/agent/subagent/types.ts b/packages/core/src/agent/subagent/types.ts index 1d3ca0ac..96277181 100644 --- a/packages/core/src/agent/subagent/types.ts +++ b/packages/core/src/agent/subagent/types.ts @@ -45,6 +45,9 @@ export type SubagentRunRecord = { findingsCaptured?: boolean | undefined; /** Whether the coalesced announcement has been sent to parent */ announced?: boolean | undefined; + /** Announcement mode: "immediate" (default) announces per-completion, + * "silent" defers until all silent runs from the same requester complete. */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for registering a new subagent run */ @@ -58,6 +61,8 @@ export type RegisterSubagentRunParams = { timeoutSeconds?: number | undefined; /** Callback invoked when the queue slot is acquired (used to defer childAgent.write). */ start?: (() => void) | undefined; + /** Announcement mode: "immediate" (default) or "silent" (defer until all silent runs complete). */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for the announce flow */ diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 4d2bf1cd..da502605 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -273,6 +273,11 @@ export function buildConditionalToolSections( "- Complex tasks (code generation, PDF creation, multi-file operations): 1200–1800 (20–30 min)", "When in doubt, use a longer timeout. It is always better to wait longer than to lose completed work.", "", + "### Announce Modes", + "- `announce: \"immediate\"` (default): Each sub-agent's findings are delivered to you as soon as it completes.", + "- `announce: \"silent\"`: All findings are held back until every silent sub-agent finishes, then delivered as ONE combined report.", + "Use \"silent\" when you want to collect data from multiple sub-agents first, then summarize everything at once.", + "", ); } diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 7c26de07..1df31e41 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -35,6 +35,15 @@ const SessionsSpawnSchema = Type.Object({ minimum: 0, }), ), + announce: Type.Optional( + Type.Union([Type.Literal("immediate"), Type.Literal("silent")], { + description: + "Announcement mode. 'immediate' (default): findings delivered as each subagent completes. " + + "'silent': defer all announcements until every silent subagent from this session finishes, " + + "then deliver one combined report. Use 'silent' when spawning multiple subagents to collect " + + "data in parallel and you want to summarize everything at once.", + }), + ), }); type SessionsSpawnArgs = { @@ -43,6 +52,7 @@ type SessionsSpawnArgs = { model?: string; cleanup?: "delete" | "keep"; timeoutSeconds?: number; + announce?: "immediate" | "silent"; }; export type SessionsSpawnResult = { @@ -75,7 +85,7 @@ export function createSessionsSpawnTool( "Use this for parallelizable work, long-running analysis, or tasks that benefit from isolation.", parameters: SessionsSpawnSchema, execute: async (_toolCallId, args) => { - const { task, label, model, cleanup = "delete", timeoutSeconds } = args as SessionsSpawnArgs; + const { task, label, model, cleanup = "delete", timeoutSeconds, announce } = args as SessionsSpawnArgs; // Guard: subagents cannot spawn subagents if (options.isSubagent) { @@ -125,6 +135,7 @@ export function createSessionsSpawnTool( label, cleanup, timeoutSeconds, + announce, start: () => childAgent.write(task), }); From 64d8427ca71e4b63d72926574587e4563390a4b9 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:30:20 +0800 Subject: [PATCH 16/30] fix(agent): enforce data-web evidence fusion --- packages/core/src/agent/system-prompt/sections.test.ts | 8 ++++++++ packages/core/src/agent/system-prompt/sections.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index 1830f9fc..cb792de5 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -179,6 +179,14 @@ describe("buildConditionalToolSections", () => { expect(result.join("\n")).toContain("## Web Access"); }); + it("adds data-web fusion guidance when both data and web tools are present", () => { + const result = buildConditionalToolSections(["data", "web_search"], "full"); + const text = result.join("\n"); + expect(text).toContain("## Data Access"); + expect(text).toContain("combine them"); + expect(text).toContain("macro, policy, and breaking-news context"); + }); + it("returns empty when no conditional tools match", () => { const result = buildConditionalToolSections(["read", "write"], "full"); expect(result).toEqual([]); diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 6d696e7d..0a864709 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -240,6 +240,7 @@ export function buildConditionalToolSections( if (mode === "none" || !tools || tools.length === 0) return []; const toolSet = new Set(tools.map((t) => t.toLowerCase())); + const hasWebTools = toolSet.has("web_search") || toolSet.has("web_fetch"); const lines: string[] = []; // Memory tools @@ -275,12 +276,15 @@ export function buildConditionalToolSections( "You have access to structured financial and market data via the `data` tool.", 'Use domain="finance" with specific actions to retrieve stock prices, financial statements, SEC filings, metrics, and more.', "Always specify dates in YYYY-MM-DD format. Use period='annual' or 'quarterly' or 'ttm' for financial statements.", + hasWebTools + ? "When both data and web tools are available, combine them: use `data` for structured fundamentals, and web sources for macro, policy, and breaking-news context." + : "Use tool outputs as evidence, and clearly state assumptions when data is incomplete.", "", ); } // Web tools - if (toolSet.has("web_search") || toolSet.has("web_fetch")) { + if (hasWebTools) { lines.push( "## Web Access", "You have web access. Use it when the user asks about current events, needs up-to-date information, or requests content from URLs.", From f2adddcde75e76751855ec4f4eab5219eb8458fa Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:30:42 +0800 Subject: [PATCH 17/30] feat(skills): upgrade finance analysis workflows --- skills/dcf-valuation/SKILL.md | 14 +++- skills/finance-research/SKILL.md | 128 +++++++++++++++++++++++-------- 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/skills/dcf-valuation/SKILL.md b/skills/dcf-valuation/SKILL.md index f2f6dc7b..5fb9b299 100644 --- a/skills/dcf-valuation/SKILL.md +++ b/skills/dcf-valuation/SKILL.md @@ -1,7 +1,7 @@ --- name: DCF Valuation description: Perform Discounted Cash Flow (DCF) valuation analysis for public companies. Use when the user asks to value a stock, calculate intrinsic value, fair value, perform DCF analysis, determine if a stock is undervalued or overvalued, or estimate a price target. -version: 1.0.0 +version: 1.1.0 metadata: emoji: "\U0001F9EE" requires: @@ -17,7 +17,7 @@ disableModelInvocation: false ## Instructions -Perform a rigorous Discounted Cash Flow (DCF) valuation. Follow all steps and show your work. +Perform a rigorous Discounted Cash Flow (DCF) valuation. Follow all steps and show your work. Use external macro context when assumptions are time-sensitive (for example, risk-free rate regime shifts). ### Progress Checklist @@ -86,6 +86,14 @@ Use `data` tool with `domain="finance"` for all calls: ``` Extract: `sector` — use to determine WACC range from [sector-wacc.md](references/sector-wacc.md) +8. **Recent Event Context**: +- Pull company-specific headlines with: + ``` + action: "get_news" + params: { ticker: "[TICKER]", limit: 10 } + ``` +- Use this to flag event risk (guidance reset, litigation, regulation, one-off gains/losses) that may distort near-term FCF extrapolation. + ### Step 2: Calculate Historical FCF and Growth - Compute FCF for each of the last 5 years @@ -112,7 +120,7 @@ Where: ``` **Default assumptions:** -- Risk-free rate: ~4.0-4.5% (10-year Treasury) +- Risk-free rate: pull latest 10-year Treasury yield using `web_search` (preferred) and cite date/source. Fallback range: ~4.0-4.5%. - Equity risk premium: ~5.5% - If beta unavailable, use sector average diff --git a/skills/finance-research/SKILL.md b/skills/finance-research/SKILL.md index acf225d7..1cf68ad0 100644 --- a/skills/finance-research/SKILL.md +++ b/skills/finance-research/SKILL.md @@ -1,7 +1,7 @@ --- name: Finance Research -description: Conduct financial research and analysis including stock analysis, company fundamentals, SEC filings review, and market data retrieval. Use when the user asks about stocks, financial statements, company performance, market data, or investment analysis. -version: 1.0.0 +description: Conduct analyst-grade financial research across primary and secondary markets using structured financial data plus macro and public-information cross-checks. +version: 1.1.0 metadata: emoji: "\U0001F4CA" requires: @@ -12,13 +12,15 @@ metadata: - research - stocks - data + - macro + - sentiment userInvocable: true disableModelInvocation: false --- ## Instructions -You are conducting financial research using real market data. Use the `data` tool with `domain="finance"` and the appropriate action. +You are conducting financial research with an analyst-grade standard. Do not rely on a single source. Combine structured company data with external macro/policy/news context whenever the conclusion can be affected by market regime or recent events. ### Available Data Actions @@ -48,44 +50,108 @@ Actions: #### Company Info - `get_company_facts` — Sector, industry, employees, exchange, website. Params: `{ ticker }` -- `get_news` — Recent news articles. Params: `{ ticker, start_date?, end_date?, limit? }` +- `get_news` — Recent company news articles. Params: `{ ticker, start_date?, end_date?, limit? }` - `get_insider_trades` — Insider buying/selling (SEC Form 4). Params: `{ ticker, limit?, filing_date*? }` - `get_segmented_revenues` — Revenue by segment/geography. Params: `{ ticker, period, limit? }` #### SEC Filings - `get_filings` — List filings metadata. Params: `{ ticker, filing_type?, limit? }` - - filing_type: "10-K", "10-Q", "8-K" -- `get_filing_items` — Read specific filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` - - item: array of section names (e.g. ["Item-1A", "Item-7"] for 10-K) +- `get_filing_items` — Read filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` -### Research Workflow +### Mandatory Multi-Source Framework -1. **Understand** what financial data is needed -2. **Get context** — start with `get_price_snapshot` and `get_company_facts` for orientation -3. **Gather data** — use the appropriate actions for the analysis -4. **Analyze** — interpret data with proper financial reasoning -5. **Present** — clear findings with data tables and key takeaways +Use this structure by default for finance analysis tasks. -### Best Practices +1. **Scope & Market Type** +- Identify if this is primary market (IPO, pre-IPO, follow-on, placement) or secondary market (listed stock/sector/index). +- State region and analysis horizon (event-driven, 3-6 months, 1-3 years). -- Use `get_all_financial_statements` when you need multiple statement types (saves API calls) -- Use annual data for trend analysis, quarterly for recent performance, TTM for current state -- Cross-reference metrics: revenue growth vs cash flow growth, margins vs peers -- Always note the time period and currency when presenting financial data -- For SEC filing analysis: first `get_filings` to find relevant filings, then `get_filing_items` to read specific sections -- Common 10-K items: Item-1 (Business), Item-1A (Risk Factors), Item-7 (MD&A), Item-8 (Financial Statements) -- Common 10-Q items: Part-1,Item-1 (Financial Statements), Part-1,Item-2 (MD&A) +2. **Core Company Data (Structured)** +- Start with: `get_price_snapshot`, `get_company_facts`, `get_financial_metrics_snapshot`. +- Pull statements (`get_all_financial_statements`) and estimates as needed. -### Example: Company Analysis +3. **Macro & Policy Context (External)** +- Use `web_search` for current policy/rates/inflation/liquidity context relevant to the asset. +- Use `web_fetch` to read high-signal primary sources (central bank, regulator, official releases). +- For time-sensitive analysis, include at least 2 external macro/policy signals with dates. -For "Analyze Apple's financial health": +4. **News & Sentiment Context (Hybrid)** +- Pull `get_news` for company-linked coverage. +- Use `web_search` to cross-check major events, management guidance changes, supply-chain/regulatory headlines, and consensus narrative. -``` -1. data(domain="finance", action="get_price_snapshot", params={ticker: "AAPL"}) -2. data(domain="finance", action="get_company_facts", params={ticker: "AAPL"}) -3. data(domain="finance", action="get_all_financial_statements", params={ticker: "AAPL", period: "annual", limit: 3}) -4. data(domain="finance", action="get_financial_metrics_snapshot", params={ticker: "AAPL"}) -5. data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL"}) -``` +5. **Synthesis & Decision** +- Separate **facts**, **inference**, and **assumptions**. +- Build bull/base/bear scenarios with explicit trigger conditions. +- Provide confidence level and explain the main uncertainty drivers. -Then analyze trends, margins, growth rates, and present findings. +### Primary Market (一级市场) Workflow + +When asked about IPOs, pre-IPO, or new issuance: + +1. **Deal Basics** +- Identify issuer, listing venue, offering structure (primary/secondary shares), expected timeline. + +2. **Filing/Prospectus Review** +- Prefer official documents (e.g., S-1/F-1/prospectus) via `web_search` + `web_fetch`. +- Extract: use of proceeds, customer concentration, related-party transactions, share classes, lock-up, dilution risks. + +3. **Valuation & Comparable Set** +- Build peer set from listed comps (secondary market tickers) and compare growth, margin, and valuation multiples. +- Flag gaps between issuer narrative and peer reality. + +4. **Deal Risk Map** +- Highlight red flags: weak FCF quality, aggressive non-GAAP adjustments, concentrated revenue, regulatory overhang. +- Provide post-listing watch items: lock-up expiry, first earnings, guidance revisions. + +### Secondary Market (二级市场) Workflow + +When asked about listed equities: + +1. **Trend & Positioning** +- Pull 1y price history (`get_prices`) and identify regime (uptrend/range/downtrend) with volatility context. + +2. **Fundamentals** +- Analyze growth quality (revenue vs FCF), margin durability, leverage, and capital allocation. + +3. **Valuation** +- Compare current multiples to historical bands and peers (when peer data is available). +- Connect valuation premium/discount to expected growth and risk profile. + +4. **Catalysts & Risks** +- Earnings, guidance, product cycle, policy changes, rates/FX/commodity sensitivity, insider activity. + +### Output Standard + +Always include: + +1. **Executive Summary** (thesis + stance + confidence) +2. **Evidence Table** with columns: +- Signal +- Direction (Bull/Bear/Neutral) +- Why it matters +- Source +- Date +3. **Scenario Table** (bull/base/bear with probabilities or relative weights) +4. **Key Monitoring Triggers** (what would invalidate current thesis) + +### Guardrails + +- Always state data cutoff dates. +- If data is missing, explicitly mark it and show the impact on confidence. +- Do not present assumptions as facts. +- Prefer source diversity: structured finance data + at least one external source for event-driven conclusions. + +### Example: Secondary Market Analysis + +For "Analyze Apple's investment outlook": + +1. `data(domain="finance", action="get_price_snapshot", params={ticker: "AAPL"})` +2. `data(domain="finance", action="get_company_facts", params={ticker: "AAPL"})` +3. `data(domain="finance", action="get_all_financial_statements", params={ticker: "AAPL", period: "annual", limit: 3})` +4. `data(domain="finance", action="get_financial_metrics", params={ticker: "AAPL", period: "quarterly", limit: 8})` +5. `data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL", period: "annual"})` +6. `data(domain="finance", action="get_news", params={ticker: "AAPL", limit: 10})` +7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` +8. `web_search(query="Apple supply chain or regulatory news latest quarter")` + +Then synthesize fundamental trend, macro regime, and event sentiment into a scenario-based conclusion. From c5db9bf232ca2dc78d32065c75ed15ef335eb02c Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:48:31 +0800 Subject: [PATCH 18/30] refactor(agent): make finance web usage dynamic --- .../core/src/agent/system-prompt/sections.test.ts | 11 +++++++---- packages/core/src/agent/system-prompt/sections.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index cb792de5..55daae5c 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -176,15 +176,18 @@ describe("buildConditionalToolSections", () => { it("includes web access section when web tools present", () => { const result = buildConditionalToolSections(["web_search"], "full"); - expect(result.join("\n")).toContain("## Web Access"); + const text = result.join("\n"); + expect(text).toContain("## Web Access"); + expect(text).toContain("Web usage is conditional, not mandatory"); }); - it("adds data-web fusion guidance when both data and web tools are present", () => { + it("adds dynamic evidence decision guidance when both data and web tools are present", () => { const result = buildConditionalToolSections(["data", "web_search"], "full"); const text = result.join("\n"); expect(text).toContain("## Data Access"); - expect(text).toContain("combine them"); - expect(text).toContain("macro, policy, and breaking-news context"); + expect(text).toContain("dynamic evidence decision"); + expect(text).toContain("Tool Decision"); + expect(text).toContain("plan (`data_only` | `hybrid` | `web_first`)"); }); it("returns empty when no conditional tools match", () => { diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 0a864709..2fe2cf8b 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -271,15 +271,23 @@ export function buildConditionalToolSections( // Data tools if (toolSet.has("data")) { - lines.push( + const dataLines = [ "## Data Access", "You have access to structured financial and market data via the `data` tool.", 'Use domain="finance" with specific actions to retrieve stock prices, financial statements, SEC filings, metrics, and more.', "Always specify dates in YYYY-MM-DD format. Use period='annual' or 'quarterly' or 'ttm' for financial statements.", hasWebTools - ? "When both data and web tools are available, combine them: use `data` for structured fundamentals, and web sources for macro, policy, and breaking-news context." + ? "When both data and web tools are available, make a dynamic evidence decision: start from structured data, and use web tools only when external validation is needed (for example: event-driven, time-sensitive, or conflicting/incomplete evidence)." : "Use tool outputs as evidence, and clearly state assumptions when data is incomplete.", + ...(hasWebTools + ? [ + "Before final conclusions, include a short `Tool Decision` summary with: plan (`data_only` | `hybrid` | `web_first`), reason, and missing evidence impact.", + ] + : []), "", + ]; + lines.push( + ...dataLines, ); } @@ -289,6 +297,7 @@ export function buildConditionalToolSections( "## Web Access", "You have web access. Use it when the user asks about current events, needs up-to-date information, or requests content from URLs.", "Prefer web_search for discovery and web_fetch for specific URLs.", + "Web usage is conditional, not mandatory: call web tools when they materially improve evidence quality.", "", ); } From bbda13d0059fab2b0160ae5641f5d1e60453c7ca Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:48:44 +0800 Subject: [PATCH 19/30] feat(skills): add finance evidence sufficiency gate --- skills/finance-research/SKILL.md | 60 ++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/skills/finance-research/SKILL.md b/skills/finance-research/SKILL.md index 1cf68ad0..22fba8a5 100644 --- a/skills/finance-research/SKILL.md +++ b/skills/finance-research/SKILL.md @@ -20,7 +20,7 @@ disableModelInvocation: false ## Instructions -You are conducting financial research with an analyst-grade standard. Do not rely on a single source. Combine structured company data with external macro/policy/news context whenever the conclusion can be affected by market regime or recent events. +You are conducting financial research with an analyst-grade standard. Tool usage is a dynamic decision. Do not force tool combinations. Choose tools based on evidence sufficiency for the specific question. ### Available Data Actions @@ -58,9 +58,26 @@ Actions: - `get_filings` — List filings metadata. Params: `{ ticker, filing_type?, limit? }` - `get_filing_items` — Read filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` -### Mandatory Multi-Source Framework +### Evidence Sufficiency Gate (Dynamic Tool Decision) -Use this structure by default for finance analysis tasks. +Before deep analysis, output a short `Tool Decision` block: + +```text +Tool Decision +- plan: data_only | hybrid | web_first +- reason: why this plan is sufficient +- missing_evidence: what is still unknown +- confidence_impact: low | medium | high +``` + +Decision policy: + +- Start with `data_only` when structured data can support the requested conclusion. +- Escalate to `hybrid` when the task is event-driven, time-sensitive, or requires causal explanation not visible in structured data alone. +- Use `web_first` only when the task is mainly document/news/policy driven (common in pre-IPO without stable ticker coverage). +- If a tool is unavailable, continue with available tools and explicitly downgrade confidence. + +### Core Analysis Framework 1. **Scope & Market Type** - Identify if this is primary market (IPO, pre-IPO, follow-on, placement) or secondary market (listed stock/sector/index). @@ -70,14 +87,14 @@ Use this structure by default for finance analysis tasks. - Start with: `get_price_snapshot`, `get_company_facts`, `get_financial_metrics_snapshot`. - Pull statements (`get_all_financial_statements`) and estimates as needed. -3. **Macro & Policy Context (External)** -- Use `web_search` for current policy/rates/inflation/liquidity context relevant to the asset. -- Use `web_fetch` to read high-signal primary sources (central bank, regulator, official releases). -- For time-sensitive analysis, include at least 2 external macro/policy signals with dates. +3. **Macro & Policy Context (Conditional)** +- Use `web_search` / `web_fetch` only if required by your `Tool Decision`. +- If used, prefer high-signal primary sources (central bank, regulator, official releases). +- For time-sensitive conclusions, include source dates explicitly. -4. **News & Sentiment Context (Hybrid)** -- Pull `get_news` for company-linked coverage. -- Use `web_search` to cross-check major events, management guidance changes, supply-chain/regulatory headlines, and consensus narrative. +4. **News & Sentiment Context (Conditional)** +- Use `get_news` for company-linked coverage when available. +- Add web cross-checks only when event validation materially affects the conclusion. 5. **Synthesis & Decision** - Separate **facts**, **inference**, and **assumptions**. @@ -90,11 +107,16 @@ When asked about IPOs, pre-IPO, or new issuance: 1. **Deal Basics** - Identify issuer, listing venue, offering structure (primary/secondary shares), expected timeline. +- Determine whether a reliable ticker exists in current data coverage. 2. **Filing/Prospectus Review** - Prefer official documents (e.g., S-1/F-1/prospectus) via `web_search` + `web_fetch`. - Extract: use of proceeds, customer concentration, related-party transactions, share classes, lock-up, dilution risks. +Primary-market capability boundary: +- If `ticker` is available and filings are retrievable, run hybrid analysis (structured + document evidence). +- If `ticker` is unavailable or structured filing fields are limited, run web-led analysis and clearly label it as partial-coverage with reduced confidence. + 3. **Valuation & Comparable Set** - Build peer set from listed comps (secondary market tickers) and compare growth, margin, and valuation multiples. - Flag gaps between issuer narrative and peer reality. @@ -124,24 +146,26 @@ When asked about listed equities: Always include: -1. **Executive Summary** (thesis + stance + confidence) -2. **Evidence Table** with columns: +1. **Tool Decision** (plan + reason + evidence gap impact) +2. **Executive Summary** (thesis + stance + confidence) +3. **Evidence Table** with columns: - Signal - Direction (Bull/Bear/Neutral) - Why it matters - Source - Date -3. **Scenario Table** (bull/base/bear with probabilities or relative weights) -4. **Key Monitoring Triggers** (what would invalidate current thesis) +4. **Scenario Table** (bull/base/bear with probabilities or relative weights) +5. **Key Monitoring Triggers** (what would invalidate current thesis) ### Guardrails - Always state data cutoff dates. - If data is missing, explicitly mark it and show the impact on confidence. - Do not present assumptions as facts. -- Prefer source diversity: structured finance data + at least one external source for event-driven conclusions. +- For event-driven conclusions, if you skip web validation, explicitly explain why structured evidence is still sufficient. -### Example: Secondary Market Analysis + +### Example: Secondary Market Analysis (Tool Decision = `hybrid`) For "Analyze Apple's investment outlook": @@ -151,7 +175,7 @@ For "Analyze Apple's investment outlook": 4. `data(domain="finance", action="get_financial_metrics", params={ticker: "AAPL", period: "quarterly", limit: 8})` 5. `data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL", period: "annual"})` 6. `data(domain="finance", action="get_news", params={ticker: "AAPL", limit: 10})` -7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` -8. `web_search(query="Apple supply chain or regulatory news latest quarter")` +7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` (only because plan=`hybrid`) +8. `web_search(query="Apple supply chain or regulatory news latest quarter")` (only because plan=`hybrid`) Then synthesize fundamental trend, macro regime, and event sentiment into a scenario-based conclusion. From 5cccb066f10696e3e2763eaa0565dbce92271ed3 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:56:31 +0800 Subject: [PATCH 20/30] refactor(agent): keep finance tool decisions internal --- packages/core/src/agent/system-prompt/sections.test.ts | 4 ++-- packages/core/src/agent/system-prompt/sections.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index 55daae5c..00549efe 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -186,8 +186,8 @@ describe("buildConditionalToolSections", () => { const text = result.join("\n"); expect(text).toContain("## Data Access"); expect(text).toContain("dynamic evidence decision"); - expect(text).toContain("Tool Decision"); - expect(text).toContain("plan (`data_only` | `hybrid` | `web_first`)"); + expect(text).toContain("Make this evidence decision internally"); + expect(text).toContain("user-facing research rationale"); }); it("returns empty when no conditional tools match", () => { diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 2fe2cf8b..52809aad 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -281,7 +281,7 @@ export function buildConditionalToolSections( : "Use tool outputs as evidence, and clearly state assumptions when data is incomplete.", ...(hasWebTools ? [ - "Before final conclusions, include a short `Tool Decision` summary with: plan (`data_only` | `hybrid` | `web_first`), reason, and missing evidence impact.", + "Make this evidence decision internally. In final answers, present concise user-facing research rationale instead of technical decision labels unless the user asks for methodology details.", ] : []), "", From 695d001f9f4f6a1b79603df49ff21be76ead2bbe Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 18:56:35 +0800 Subject: [PATCH 21/30] refactor(skills): hide technical finance decision block --- skills/finance-research/SKILL.md | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/skills/finance-research/SKILL.md b/skills/finance-research/SKILL.md index 22fba8a5..18bee569 100644 --- a/skills/finance-research/SKILL.md +++ b/skills/finance-research/SKILL.md @@ -58,17 +58,11 @@ Actions: - `get_filings` — List filings metadata. Params: `{ ticker, filing_type?, limit? }` - `get_filing_items` — Read filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` -### Evidence Sufficiency Gate (Dynamic Tool Decision) +### Evidence Sufficiency Gate (Internal Decision) -Before deep analysis, output a short `Tool Decision` block: +Before deep analysis, make an internal evidence decision. Do not output a technical decision block by default. -```text -Tool Decision -- plan: data_only | hybrid | web_first -- reason: why this plan is sufficient -- missing_evidence: what is still unknown -- confidence_impact: low | medium | high -``` +If the user explicitly asks for methodology or reasoning transparency, provide a concise plain-language explanation of your research approach. Decision policy: @@ -88,7 +82,7 @@ Decision policy: - Pull statements (`get_all_financial_statements`) and estimates as needed. 3. **Macro & Policy Context (Conditional)** -- Use `web_search` / `web_fetch` only if required by your `Tool Decision`. +- Use `web_search` / `web_fetch` only if required by your internal evidence decision. - If used, prefer high-signal primary sources (central bank, regulator, official releases). - For time-sensitive conclusions, include source dates explicitly. @@ -146,16 +140,15 @@ When asked about listed equities: Always include: -1. **Tool Decision** (plan + reason + evidence gap impact) -2. **Executive Summary** (thesis + stance + confidence) -3. **Evidence Table** with columns: +1. **Executive Summary** (thesis + stance + confidence) +2. **Evidence Table** with columns: - Signal - Direction (Bull/Bear/Neutral) - Why it matters - Source - Date -4. **Scenario Table** (bull/base/bear with probabilities or relative weights) -5. **Key Monitoring Triggers** (what would invalidate current thesis) +3. **Scenario Table** (bull/base/bear with probabilities or relative weights) +4. **Key Monitoring Triggers** (what would invalidate current thesis) ### Guardrails @@ -165,7 +158,7 @@ Always include: - For event-driven conclusions, if you skip web validation, explicitly explain why structured evidence is still sufficient. -### Example: Secondary Market Analysis (Tool Decision = `hybrid`) +### Example: Secondary Market Analysis For "Analyze Apple's investment outlook": @@ -175,7 +168,7 @@ For "Analyze Apple's investment outlook": 4. `data(domain="finance", action="get_financial_metrics", params={ticker: "AAPL", period: "quarterly", limit: 8})` 5. `data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL", period: "annual"})` 6. `data(domain="finance", action="get_news", params={ticker: "AAPL", limit: 10})` -7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` (only because plan=`hybrid`) -8. `web_search(query="Apple supply chain or regulatory news latest quarter")` (only because plan=`hybrid`) +7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` +8. `web_search(query="Apple supply chain or regulatory news latest quarter")` Then synthesize fundamental trend, macro regime, and event sentiment into a scenario-based conclusion. From 47cbf60687b2e8635aea0719803d24a90c3c6718 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 18:58:24 +0800 Subject: [PATCH 22/30] fix(agent): reload tools after setProvider to fix stale closure setProvider() updated toolsOptions.provider but didn't reload tool instances. The sessions_spawn tool captured the old provider in its closure at creation time, causing subagents to inherit a stale provider. Now calls resolveTools() + setTools() after updating toolsOptions so sessions_spawn gets a fresh closure with the correct provider. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/runner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index b5ac016f..636a263d 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -935,6 +935,11 @@ export class Agent { // Keep toolsOptions.provider in sync so sessions_spawn inherits the current provider this.toolsOptions = { ...this.toolsOptions, provider: providerId }; + // Reload tools so sessions_spawn picks up the new provider in its closure. + // Without this, the existing tool instance still captures the old provider. + const tools = resolveTools(this.toolsOptions); + this.agent.setTools(tools); + // Update session metadata (save original providerId, not alias-resolved) this.session.saveMeta({ provider: providerId, @@ -945,7 +950,7 @@ export class Agent { }); // Rebuild system prompt so runtime info reflects the new provider/model - const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name); + const toolNames = tools.map((t) => t.name); const systemPrompt = this.rebuildSystemPrompt(toolNames); if (systemPrompt) { this.agent.setSystemPrompt(systemPrompt); From af3a42a00e358c4c195753f5a016a1c8a7f12d62 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 18:58:32 +0800 Subject: [PATCH 23/30] fix(subagent): forward announcement summary to UI stream Changed forwardAssistant from false to true in sendAnnounceDirect() so the assistant's summary response is streamed to the desktop UI in real-time. The announcement prompt stays internal (hidden from UI), but the user now sees the completion notification. Previously, persistAssistantSummary saved the response to JSONL but never emitted events to the UI subscriber, leaving users with no visible feedback after subagent tasks completed. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/subagent/announce.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/subagent/announce.ts b/packages/core/src/agent/subagent/announce.ts index 3532d1af..c1cf3330 100644 --- a/packages/core/src/agent/subagent/announce.ts +++ b/packages/core/src/agent/subagent/announce.ts @@ -281,10 +281,10 @@ export function formatCoalescedAnnouncementMessage( * later drain via writeInternal (with debounce to batch nearby completions) * 2. Direct — if parent is idle, send immediately via writeInternal * - * All delivery uses writeInternal() which marks entries as `internal: true`, - * preventing announcement messages from showing as user bubbles in the UI. - * We avoid steer() (cancels unrelated tool calls) and followUp() (doesn't - * mark entries as internal, polluting the chat UI). + * All delivery uses writeInternal() which marks the announcement prompt as + * `internal: true` (hidden from UI). The assistant's summary response is + * forwarded to the real-time stream (`forwardAssistant: true`) so the user + * sees the result, and persisted to JSONL for future session loads. */ export function runCoalescedAnnounceFlow( requesterSessionId: string, @@ -345,7 +345,7 @@ function sendAnnounceDirect(requesterSessionId: string, message: string): void { ); return; } - parentAgent.writeInternal(message, { forwardAssistant: false, persistResponse: true }); + parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); } catch (err) { console.error(`[SubagentAnnounce] Failed direct announce to parent:`, err); } @@ -391,7 +391,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean return false; } - parentAgent.writeInternal(message, { forwardAssistant: false, persistResponse: true }); + parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); return true; } catch (err) { console.error(`[SubagentAnnounce] Failed to announce to parent:`, err); From 8d6a8037397af4dd91c0f330f1b360980860b24e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 19:34:23 +0800 Subject: [PATCH 24/30] fix(agent): filter heartbeat ACK messages from desktop event stream The heartbeat runner uses agent.write() (normal write), so heartbeat ACK responses like "HEARTBEAT_OK" were not suppressed by the internal run filter and leaked into the desktop UI chat. The Gateway path was already fixed (Hub has delayed-start + isHeartbeatAckEvent filtering), but the local Desktop path through AsyncAgent had no such filtering. Add createFilteredHandler() to AsyncAgent that buffers message_start for assistant messages and checks subsequent events with isHeartbeatAckEvent(). Pure heartbeat ACKs are suppressed end-to-end; all other messages are forwarded normally. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/async-agent.ts | 82 +++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agent/async-agent.ts b/packages/core/src/agent/async-agent.ts index 41c33778..ce11f17a 100644 --- a/packages/core/src/agent/async-agent.ts +++ b/packages/core/src/agent/async-agent.ts @@ -6,6 +6,7 @@ import type { AgentOptions, Message } from "./types.js"; import type { MulticaEvent } from "./events.js"; import { injectMessageTimestamp } from "./message-timestamp.js"; import { isSilentReplyText } from "./tokens.js"; +import { isHeartbeatAckEvent } from "../hub/heartbeat-filter.js"; const devNull = { write: () => true } as unknown as NodeJS.WritableStream; @@ -45,10 +46,10 @@ export class AsyncAgent { // Forward raw AgentEvent and MulticaEvent into the channel. // Suppress forwarding during internal runs to avoid leaking // orchestration messages to the frontend/real-time stream. - this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => { - if (!this.shouldForwardEvent(event)) return; - this.channel.send(event); - }); + // Also suppresses pure heartbeat ACK messages (e.g. "HEARTBEAT_OK"). + this.agent.subscribeAll( + this.createFilteredHandler((event) => this.channel.send(event)), + ); } get closed(): boolean { @@ -160,11 +161,12 @@ export class AsyncAgent { */ subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void { console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`); - const unsubscribe = this.agent.subscribeAll((event) => { - if (!this.shouldForwardEvent(event)) return; - console.log(`[AsyncAgent] Event received: ${event.type}`); - callback(event); - }); + const unsubscribe = this.agent.subscribeAll( + this.createFilteredHandler((event) => { + console.log(`[AsyncAgent] Event received: ${event.type}`); + callback(event); + }), + ); return () => { console.log(`[AsyncAgent] Removing subscriber for agent: ${this.sessionId}`); unsubscribe(); @@ -225,6 +227,68 @@ export class AsyncAgent { return (maybeMessage as { role?: unknown }).role === "assistant"; } + /** + * Wrap a forwarding callback with shouldForwardEvent + heartbeat ACK suppression. + * + * Mirrors Hub's pattern: buffer `message_start` for assistant messages, then + * check subsequent events with `isHeartbeatAckEvent()`. If the message is a + * pure heartbeat ACK (e.g. "HEARTBEAT_OK"), suppress the entire sequence. + * Otherwise flush the buffered start and forward normally. + */ + private createFilteredHandler( + forward: (event: AgentEvent | MulticaEvent) => void, + ): (event: AgentEvent | MulticaEvent) => void { + let pendingStart: (AgentEvent | MulticaEvent) | null = null; + + return (event: AgentEvent | MulticaEvent) => { + if (!this.shouldForwardEvent(event)) return; + + const isAssistantMsg = this.isAssistantMessageEvent(event); + + if (!isAssistantMsg) { + // Non-assistant event: flush any pending start, then forward + if (pendingStart) { + forward(pendingStart); + pendingStart = null; + } + forward(event); + return; + } + + // Assistant message event — apply heartbeat ACK suppression + if (event.type === "message_start") { + pendingStart = event; + return; + } + + // Check if this is a heartbeat ACK on content/end events + if (isHeartbeatAckEvent(event)) { + if (event.type === "message_end") { + // Entire message was a heartbeat ACK — suppress it + pendingStart = null; + } + return; + } + + // Not a heartbeat ACK — flush buffered start if present, then forward + if (pendingStart) { + forward(pendingStart); + pendingStart = null; + } + forward(event); + }; + } + + /** Check if an event is an assistant message event (message_start/update/end with role=assistant) */ + private isAssistantMessageEvent(event: AgentEvent | MulticaEvent): boolean { + if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") { + return false; + } + const maybeMessage = (event as { message?: unknown }).message; + if (!maybeMessage || typeof maybeMessage !== "object") return false; + return (maybeMessage as { role?: unknown }).role === "assistant"; + } + /** Register a callback to be invoked when the agent is closed */ onClose(callback: () => void): void { if (this._closed) { From c49ab1fee442e46485901bbd10449f0bac916524 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 19:41:57 +0800 Subject: [PATCH 25/30] fix(heartbeat): use internal run to hide heartbeat from chat history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat runner used agent.write() (normal write), which persisted both the heartbeat prompt and HEARTBEAT_OK response as regular messages visible in the UI chat history. Switch to runInternalForResult() — a new AsyncAgent method that runs via runInternal() (messages marked internal: true, rolled back from in-memory state). This hides both the heartbeat prompt and response from the UI entirely, while still persisting to JSONL for diagnostics. The previous commit's event-stream heartbeat ACK filter remains as a defense-in-depth layer for edge cases. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/async-agent.ts | 27 ++++++++++++++++++ packages/core/src/heartbeat/runner.test.ts | 28 +++++++------------ packages/core/src/heartbeat/runner.ts | 32 ++++------------------ 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/packages/core/src/agent/async-agent.ts b/packages/core/src/agent/async-agent.ts index ce11f17a..e7ff1957 100644 --- a/packages/core/src/agent/async-agent.ts +++ b/packages/core/src/agent/async-agent.ts @@ -149,6 +149,33 @@ export class AsyncAgent { }); } + /** + * Run an internal prompt and return the result. + * Serialized through the write queue. Messages are persisted with + * `internal: true` and rolled back from in-memory state, so they + * won't appear in UI history or `getMessages()`. + */ + runInternalForResult(content: string): Promise<{ text: string; error?: string }> { + if (this._closed) return Promise.resolve({ text: "", error: "Agent is closed" }); + return new Promise((resolve) => { + this.queue = this.queue + .then(async () => { + if (this._closed) { + resolve({ text: "", error: "Agent is closed" }); + return; + } + const result = await this.agent.runInternal(content); + await this.agent.flushSession(); + resolve({ text: result.text, error: result.error }); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`[AsyncAgent] runInternalForResult failed: ${message}`); + resolve({ text: "", error: message }); + }); + }); + } + /** Continuously read channel stream (AgentEvent + error Messages) */ read(): AsyncIterable { return this.channel; diff --git a/packages/core/src/heartbeat/runner.test.ts b/packages/core/src/heartbeat/runner.test.ts index 1a04bc5f..f6691a5c 100644 --- a/packages/core/src/heartbeat/runner.test.ts +++ b/packages/core/src/heartbeat/runner.test.ts @@ -8,9 +8,7 @@ type StubAgent = { closed: boolean; sessionId: string; ensureInitialized: () => Promise; - getMessages: () => Array; - write: (content: string, options?: { injectTimestamp?: boolean }) => void; - waitForIdle: () => Promise; + runInternalForResult: (content: string) => Promise<{ text: string; error?: string }>; getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean }; getPendingWrites: () => number; getProfileDir: () => string | undefined; @@ -21,19 +19,13 @@ function createStubAgent(opts?: { replyText?: string; heartbeatEnabled?: boolean; }): StubAgent { - const messages: Array = []; const replyText = opts?.replyText ?? "HEARTBEAT_OK"; return { closed: false, sessionId: "test-session", ensureInitialized: async () => {}, - getMessages: () => messages, - write: (content: string) => { - messages.push({ role: "user", content }); - messages.push({ role: "assistant", content: [{ type: "text", text: replyText }] }); - }, - waitForIdle: async () => {}, + runInternalForResult: async () => ({ text: replyText }), getHeartbeatConfig: () => typeof opts?.heartbeatEnabled === "boolean" ? { enabled: opts.heartbeatEnabled } @@ -84,18 +76,18 @@ describe("heartbeat runner", () => { expect(result.status).toBe("ran"); }); - it("disables timestamp injection for heartbeat prompt writes", async () => { - const writes: Array<{ content: string; options?: { injectTimestamp?: boolean } }> = []; + it("uses runInternalForResult for heartbeat execution", async () => { + const calls: string[] = []; const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); - const originalWrite = agent.write; - agent.write = (content, options) => { - writes.push(options ? { content, options } : { content }); - originalWrite(content, options); + agent.runInternalForResult = async (content: string) => { + calls.push(content); + return { text: "HEARTBEAT_OK" }; }; await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); - expect(writes.length).toBeGreaterThan(0); - expect(writes[0]?.options?.injectTimestamp).toBe(false); + expect(calls.length).toBeGreaterThan(0); + // The prompt should contain heartbeat instructions + expect(calls[0]).toContain("heartbeat"); }); }); diff --git a/packages/core/src/heartbeat/runner.ts b/packages/core/src/heartbeat/runner.ts index 7e6dab53..3ca4d7a0 100644 --- a/packages/core/src/heartbeat/runner.ts +++ b/packages/core/src/heartbeat/runner.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AsyncAgent } from "../agent/async-agent.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -73,22 +72,6 @@ function resolveDurationMs(raw: string | undefined): number | null { return null; } -function extractMessageText(message: AgentMessage | undefined): string { - if (!message) return ""; - const raw = (message as { content?: unknown }).content; - if (typeof raw === "string") return raw; - if (!Array.isArray(raw)) return ""; - - const parts: string[] = []; - for (const block of raw) { - if (!block || typeof block !== "object") continue; - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - parts.push(text); - } - } - return parts.join("\n").trim(); -} function getHeartbeatConfig(agent: AsyncAgent | null): HeartbeatConfig { const cfg = agent?.getHeartbeatConfig(); @@ -167,7 +150,6 @@ export async function runHeartbeatOnce(opts: { } await agent.ensureInitialized(); - const beforeMessages = agent.getMessages(); const sessionKey = resolveSessionKey(agent); const pendingEvents = drainSystemEvents(sessionKey); @@ -176,15 +158,11 @@ export async function runHeartbeatOnce(opts: { ? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}` : basePrompt; - agent.write(prompt, { injectTimestamp: false }); - await agent.waitForIdle(); - - const afterMessages = agent.getMessages(); - const appended = afterMessages.slice(beforeMessages.length); - const assistant = [...appended] - .reverse() - .find((msg) => msg.role === "assistant"); - const text = extractMessageText(assistant); + const result = await agent.runInternalForResult(prompt); + if (result.error) { + throw new Error(result.error); + } + const text = result.text; if (!text.trim()) { const okEmptyEvent: Omit = { From 4c4f8989ca20350bb74435c765bb1f88f4d2f247 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 20:02:30 +0800 Subject: [PATCH 26/30] feat(agent): add internal finance evidence decisioner --- .../agent/research/finance-decisioner.test.ts | 68 ++++ .../src/agent/research/finance-decisioner.ts | 304 ++++++++++++++++++ packages/core/src/agent/runner.ts | 48 ++- packages/core/src/agent/session/types.ts | 9 + 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/agent/research/finance-decisioner.test.ts create mode 100644 packages/core/src/agent/research/finance-decisioner.ts diff --git a/packages/core/src/agent/research/finance-decisioner.test.ts b/packages/core/src/agent/research/finance-decisioner.test.ts new file mode 100644 index 00000000..af7daec4 --- /dev/null +++ b/packages/core/src/agent/research/finance-decisioner.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildInternalFinanceGuidance, decideFinanceEvidencePlan } from "./finance-decisioner.js"; + +describe("decideFinanceEvidencePlan", () => { + it("returns undefined for non-finance prompts", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Write a TypeScript utility to parse CSV files.", + tools: ["read", "write", "exec"], + }); + expect(result).toBeUndefined(); + }); + + it("prefers data_only for secondary market non-event tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Analyze AAPL valuation based on 5-year financial statements.", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.plan).toBe("data_only"); + expect(result?.marketRoute).toBe("secondary"); + expect(result?.reasons).toContain("secondary_market_task"); + }); + + it("prefers hybrid for event-driven secondary tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Why did AAPL drop after latest earnings and guidance update?", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.plan).toBe("hybrid"); + expect(result?.reasons).toContain("event_driven"); + expect(result?.reasons).toContain("causal_explanation_needed"); + }); + + it("prefers web_first for primary market tasks without ticker", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Review this pre-IPO issuance structure and lock-up risks.", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.marketRoute).toBe("primary_no_ticker"); + expect(result?.plan).toBe("web_first"); + }); + + it("degrades when web tools are unavailable for event-driven tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Analyze latest earnings surprise drivers for TSLA stock.", + tools: ["data"], + }); + expect(result).toBeDefined(); + expect(result?.reasons).toContain("web_tools_unavailable"); + expect(result?.confidencePenalty).toBe("high"); + }); +}); + +describe("buildInternalFinanceGuidance", () => { + it("formats internal guidance text", () => { + const decision = decideFinanceEvidencePlan({ + prompt: "Analyze latest AAPL earnings impact on valuation.", + tools: ["data", "web_search"], + }); + expect(decision).toBeDefined(); + const guidance = buildInternalFinanceGuidance(decision!); + expect(guidance).toContain("Internal Finance Research Guidance"); + expect(guidance).toContain("Preferred evidence plan:"); + expect(guidance).toContain("Do not expose technical labels"); + }); +}); diff --git a/packages/core/src/agent/research/finance-decisioner.ts b/packages/core/src/agent/research/finance-decisioner.ts new file mode 100644 index 00000000..7df1be8d --- /dev/null +++ b/packages/core/src/agent/research/finance-decisioner.ts @@ -0,0 +1,304 @@ +/** + * Finance evidence decisioner. + * + * Produces an internal research plan for finance tasks: + * - data_only + * - hybrid (data + web validation) + * - web_first + * + * The output is intended for internal orchestration only. + */ + +export type FinanceEvidencePlan = "data_only" | "hybrid" | "web_first"; + +export type FinanceMarketRoute = "secondary" | "primary_with_ticker" | "primary_no_ticker"; + +export type FinanceConfidencePenalty = "low" | "medium" | "high"; + +export interface FinanceDecisionInput { + prompt: string; + tools: string[]; +} + +export interface FinanceDecision { + plan: FinanceEvidencePlan; + marketRoute: FinanceMarketRoute; + confidencePenalty: FinanceConfidencePenalty; + reasons: string[]; + score: Record; +} + +const FINANCE_KEYWORDS = [ + "stock", + "stocks", + "equity", + "equities", + "valuation", + "financial", + "finance", + "earnings", + "revenue", + "eps", + "cash flow", + "balance sheet", + "income statement", + "pe ratio", + "market cap", + "ipo", + "pre-ipo", + "listing", + "ticker", + "一级市场", + "二级市场", + "财报", + "股票", + "估值", + "市值", + "募资", + "锁定期", + "稀释", +]; + +const PRIMARY_MARKET_KEYWORDS = [ + "ipo", + "pre-ipo", + "prospectus", + "s-1", + "f-1", + "roadshow", + "listing", + "follow-on", + "new issuance", + "lock-up", + "dilution", + "一级市场", + "募资", + "锁定期", + "稀释", +]; + +const EVENT_DRIVEN_KEYWORDS = [ + "latest", + "recent", + "today", + "yesterday", + "breaking", + "earnings call", + "guidance", + "surprise", + "selloff", + "policy", + "fed", + "fomc", + "news", + "headline", + "突发", + "最新", + "消息", + "政策", + "财报后", +]; + +const CAUSAL_KEYWORDS = [ + "why", + "reason", + "driver", + "impact", + "because", + "attribution", + "explain", + "原因", + "驱动", + "影响", + "为什么", +]; + +const TIME_SENSITIVE_KEYWORDS = [ + "latest", + "today", + "this week", + "this month", + "current", + "now", + "最新", + "当前", + "近期", +]; + +const COMMON_UPPERCASE_NON_TICKERS = new Set([ + "IPO", + "SEC", + "USD", + "CNY", + "HKD", + "GDP", + "CPI", + "PPI", + "FED", + "FOMC", + "EPS", + "FCF", + "PE", + "TTM", + "DCF", +]); + +function includesAny(text: string, keywords: string[]): boolean { + return keywords.some((keyword) => text.includes(keyword)); +} + +function normalizeTools(tools: string[]): Set { + return new Set(tools.map((tool) => tool.toLowerCase())); +} + +function hasTickerSignal(prompt: string): boolean { + const explicit = /(?:\$|ticker\s*[:=]\s*)([A-Za-z]{1,6})/g; + if (explicit.test(prompt)) return true; + + const upperWords = prompt.match(/\b[A-Z]{1,6}\b/g) ?? []; + const candidates = upperWords.filter((word) => !COMMON_UPPERCASE_NON_TICKERS.has(word)); + return candidates.length > 0; +} + +function isFinanceTask(prompt: string): boolean { + const normalized = prompt.toLowerCase(); + return includesAny(normalized, FINANCE_KEYWORDS); +} + +function resolveMarketRoute(prompt: string): FinanceMarketRoute { + const normalized = prompt.toLowerCase(); + const primary = includesAny(normalized, PRIMARY_MARKET_KEYWORDS); + if (!primary) return "secondary"; + return hasTickerSignal(prompt) ? "primary_with_ticker" : "primary_no_ticker"; +} + +function choosePlan(score: Record): FinanceEvidencePlan { + const order: FinanceEvidencePlan[] = ["data_only", "hybrid", "web_first"]; + let best: FinanceEvidencePlan = order[0]; + for (const plan of order) { + if (score[plan] > score[best]) best = plan; + } + return best; +} + +function resolveConfidencePenalty(params: { + plan: FinanceEvidencePlan; + hasData: boolean; + hasWeb: boolean; + route: FinanceMarketRoute; + eventDriven: boolean; + timeSensitive: boolean; +}): FinanceConfidencePenalty { + const { plan, hasData, hasWeb, route, eventDriven, timeSensitive } = params; + + if (!hasData && !hasWeb) return "high"; + if ((plan === "hybrid" || plan === "web_first") && !hasWeb) return "high"; + if (plan === "data_only" && (eventDriven || timeSensitive) && !hasWeb) return "high"; + if (route === "primary_no_ticker") return "medium"; + if (plan === "data_only" && (eventDriven || timeSensitive)) return "medium"; + return "low"; +} + +export function decideFinanceEvidencePlan(input: FinanceDecisionInput): FinanceDecision | undefined { + const { prompt } = input; + if (!isFinanceTask(prompt)) return undefined; + + const normalized = prompt.toLowerCase(); + const toolSet = normalizeTools(input.tools); + const hasData = toolSet.has("data"); + const hasWebSearch = toolSet.has("web_search"); + const hasWebFetch = toolSet.has("web_fetch"); + const hasWeb = hasWebSearch || hasWebFetch; + + const route = resolveMarketRoute(prompt); + const eventDriven = includesAny(normalized, EVENT_DRIVEN_KEYWORDS); + const causal = includesAny(normalized, CAUSAL_KEYWORDS); + const timeSensitive = includesAny(normalized, TIME_SENSITIVE_KEYWORDS); + + const score: Record = { + data_only: hasData ? 1.0 : -3.0, + hybrid: hasData && hasWeb ? 1.0 : -2.0, + web_first: hasWeb ? 0.6 : -3.0, + }; + + const reasons: string[] = []; + + if (route === "secondary") { + score.data_only += 0.7; + score.hybrid += 0.4; + reasons.push("secondary_market_task"); + } else if (route === "primary_with_ticker") { + score.hybrid += 0.9; + score.web_first += 0.3; + score.data_only -= 0.2; + reasons.push("primary_market_with_ticker"); + } else { + score.web_first += 1.3; + score.hybrid += 0.7; + score.data_only -= 1.0; + reasons.push("primary_market_without_ticker"); + } + + if (eventDriven) { + score.hybrid += 0.9; + score.web_first += 0.4; + score.data_only -= 0.5; + reasons.push("event_driven"); + } + + if (timeSensitive) { + score.hybrid += 0.6; + score.web_first += 0.3; + score.data_only -= 0.4; + reasons.push("time_sensitive"); + } + + if (causal) { + score.hybrid += 0.4; + score.web_first += 0.2; + score.data_only -= 0.2; + reasons.push("causal_explanation_needed"); + } + + if (!hasWeb) { + score.hybrid -= 2.0; + score.web_first -= 3.0; + reasons.push("web_tools_unavailable"); + } + if (!hasData) { + score.data_only -= 2.5; + score.hybrid -= 1.5; + score.web_first += 0.5; + reasons.push("data_tool_unavailable"); + } + + const plan = choosePlan(score); + const confidencePenalty = resolveConfidencePenalty({ + plan, + hasData, + hasWeb, + route, + eventDriven, + timeSensitive, + }); + + return { + plan, + marketRoute: route, + confidencePenalty, + reasons, + score, + }; +} + +export function buildInternalFinanceGuidance(decision: FinanceDecision): string { + return [ + "## Internal Finance Research Guidance", + "This section is internal orchestration guidance. Do not expose technical labels directly to the user unless they explicitly request methodology details.", + `Preferred evidence plan: ${decision.plan}`, + `Market route: ${decision.marketRoute}`, + `Confidence penalty if evidence gaps remain: ${decision.confidencePenalty}`, + `Decision factors: ${decision.reasons.join(", ") || "none"}`, + "Execution policy: start with the preferred plan, then escalate evidence collection if signals conflict or causality remains unresolved.", + ].join("\n"); +} diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index beaa4faf..8ea675f3 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -35,8 +35,12 @@ import { import { buildSystemPrompt as buildStructuredSystemPrompt, collectRuntimeInfo, - type SystemPromptMode, } from "./system-prompt/index.js"; +import { + buildInternalFinanceGuidance, + decideFinanceEvidencePlan, + type FinanceDecision, +} from "./research/finance-decisioner.js"; import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; import { sanitizeToolCallInputs, @@ -425,6 +429,7 @@ export class Agent { ): Promise { await this.ensureInitialized(); this.refreshAuthState(); + this.applyFinanceResearchGuidance(prompt); this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; @@ -968,6 +973,13 @@ export class Agent { * Shared by constructor (via buildFullSystemPrompt) and reloadSystemPrompt. */ private rebuildSystemPrompt(toolNames: string[]): string | undefined { + return this.rebuildSystemPromptWithExtra(toolNames); + } + + private rebuildSystemPromptWithExtra( + toolNames: string[], + extraSystemPrompt?: string | undefined, + ): string | undefined { const profile = this.profile?.getProfile(); if (!profile) return undefined; @@ -993,6 +1005,40 @@ export class Agent { tools: toolNames, skillsPrompt, runtime, + extraSystemPrompt, + }); + } + + private applyFinanceResearchGuidance(prompt: string): void { + const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name); + const decision = decideFinanceEvidencePlan({ prompt, tools: toolNames }); + + const guidance = decision ? buildInternalFinanceGuidance(decision) : undefined; + const systemPrompt = this.rebuildSystemPromptWithExtra(toolNames, guidance); + if (systemPrompt) { + this.agent.setSystemPrompt(systemPrompt); + this.session.setSystemPrompt(systemPrompt); + } + + this.saveFinanceDecisionMeta(decision); + } + + private saveFinanceDecisionMeta(decision: FinanceDecision | undefined): void { + const currentMeta = this.session.getMeta() ?? {}; + if (!decision) { + // Keep previously saved decisions for non-finance turns. + return; + } + this.session.saveMeta({ + ...currentMeta, + researchDecision: { + domain: "finance", + plan: decision.plan, + marketRoute: decision.marketRoute, + confidencePenalty: decision.confidencePenalty, + reasons: decision.reasons, + timestamp: Date.now(), + }, }); } } diff --git a/packages/core/src/agent/session/types.ts b/packages/core/src/agent/session/types.ts index ec734424..48c037a9 100644 --- a/packages/core/src/agent/session/types.ts +++ b/packages/core/src/agent/session/types.ts @@ -9,6 +9,15 @@ export type SessionMeta = { reasoningMode?: string; /** Context window token 数 */ contextWindowTokens?: number; + /** Internal finance evidence decision from the latest run */ + researchDecision?: { + domain: "finance"; + plan: "data_only" | "hybrid" | "web_first"; + marketRoute: "secondary" | "primary_with_ticker" | "primary_no_ticker"; + confidencePenalty: "low" | "medium" | "high"; + reasons: string[]; + timestamp: number; + } | undefined; }; export type SessionEntry = From f9b193d467c62e2efd4e45adeae9700ec4698a9c Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 11 Feb 2026 20:20:47 +0800 Subject: [PATCH 27/30] fix(desktop): use default import for electron-updater CJS module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-updater is a CommonJS module — ESM named imports fail at runtime. Switch to default import with destructuring. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/updater/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/updater/index.ts b/apps/desktop/src/main/updater/index.ts index 7dfab058..572b1d61 100644 --- a/apps/desktop/src/main/updater/index.ts +++ b/apps/desktop/src/main/updater/index.ts @@ -2,7 +2,10 @@ * Auto-updater module using electron-updater * Checks for updates from GitHub releases and handles download/install */ -import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater' +import pkg from 'electron-updater' +import type { UpdateInfo, ProgressInfo } from 'electron-updater' + +const { autoUpdater } = pkg import { BrowserWindow } from 'electron' export interface UpdateStatus { From 0330b8b7ca50d4672dd9069c635e967ba6965302 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 20:29:14 +0800 Subject: [PATCH 28/30] refactor(agent): remove keyword-based finance decisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finance-decisioner used hardcoded keyword matching and arbitrary scoring weights to decide evidence plans — the LLM reading the improved SKILL.md already makes this decision more intelligently. Removes: finance-decisioner.ts, its tests, runner.ts integration (applyFinanceResearchGuidance, saveFinanceDecisionMeta, rebuildSystemPromptWithExtra shim), and researchDecision from session meta. Keeps: SKILL.md improvements, sections.ts dynamic data/web guidance. Co-Authored-By: Claude Opus 4.6 --- .../agent/research/finance-decisioner.test.ts | 68 ---- .../src/agent/research/finance-decisioner.ts | 304 ------------------ packages/core/src/agent/runner.ts | 48 +-- packages/core/src/agent/session/types.ts | 9 - 4 files changed, 1 insertion(+), 428 deletions(-) delete mode 100644 packages/core/src/agent/research/finance-decisioner.test.ts delete mode 100644 packages/core/src/agent/research/finance-decisioner.ts diff --git a/packages/core/src/agent/research/finance-decisioner.test.ts b/packages/core/src/agent/research/finance-decisioner.test.ts deleted file mode 100644 index af7daec4..00000000 --- a/packages/core/src/agent/research/finance-decisioner.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildInternalFinanceGuidance, decideFinanceEvidencePlan } from "./finance-decisioner.js"; - -describe("decideFinanceEvidencePlan", () => { - it("returns undefined for non-finance prompts", () => { - const result = decideFinanceEvidencePlan({ - prompt: "Write a TypeScript utility to parse CSV files.", - tools: ["read", "write", "exec"], - }); - expect(result).toBeUndefined(); - }); - - it("prefers data_only for secondary market non-event tasks", () => { - const result = decideFinanceEvidencePlan({ - prompt: "Analyze AAPL valuation based on 5-year financial statements.", - tools: ["data", "web_search", "web_fetch"], - }); - expect(result).toBeDefined(); - expect(result?.plan).toBe("data_only"); - expect(result?.marketRoute).toBe("secondary"); - expect(result?.reasons).toContain("secondary_market_task"); - }); - - it("prefers hybrid for event-driven secondary tasks", () => { - const result = decideFinanceEvidencePlan({ - prompt: "Why did AAPL drop after latest earnings and guidance update?", - tools: ["data", "web_search", "web_fetch"], - }); - expect(result).toBeDefined(); - expect(result?.plan).toBe("hybrid"); - expect(result?.reasons).toContain("event_driven"); - expect(result?.reasons).toContain("causal_explanation_needed"); - }); - - it("prefers web_first for primary market tasks without ticker", () => { - const result = decideFinanceEvidencePlan({ - prompt: "Review this pre-IPO issuance structure and lock-up risks.", - tools: ["data", "web_search", "web_fetch"], - }); - expect(result).toBeDefined(); - expect(result?.marketRoute).toBe("primary_no_ticker"); - expect(result?.plan).toBe("web_first"); - }); - - it("degrades when web tools are unavailable for event-driven tasks", () => { - const result = decideFinanceEvidencePlan({ - prompt: "Analyze latest earnings surprise drivers for TSLA stock.", - tools: ["data"], - }); - expect(result).toBeDefined(); - expect(result?.reasons).toContain("web_tools_unavailable"); - expect(result?.confidencePenalty).toBe("high"); - }); -}); - -describe("buildInternalFinanceGuidance", () => { - it("formats internal guidance text", () => { - const decision = decideFinanceEvidencePlan({ - prompt: "Analyze latest AAPL earnings impact on valuation.", - tools: ["data", "web_search"], - }); - expect(decision).toBeDefined(); - const guidance = buildInternalFinanceGuidance(decision!); - expect(guidance).toContain("Internal Finance Research Guidance"); - expect(guidance).toContain("Preferred evidence plan:"); - expect(guidance).toContain("Do not expose technical labels"); - }); -}); diff --git a/packages/core/src/agent/research/finance-decisioner.ts b/packages/core/src/agent/research/finance-decisioner.ts deleted file mode 100644 index 7df1be8d..00000000 --- a/packages/core/src/agent/research/finance-decisioner.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Finance evidence decisioner. - * - * Produces an internal research plan for finance tasks: - * - data_only - * - hybrid (data + web validation) - * - web_first - * - * The output is intended for internal orchestration only. - */ - -export type FinanceEvidencePlan = "data_only" | "hybrid" | "web_first"; - -export type FinanceMarketRoute = "secondary" | "primary_with_ticker" | "primary_no_ticker"; - -export type FinanceConfidencePenalty = "low" | "medium" | "high"; - -export interface FinanceDecisionInput { - prompt: string; - tools: string[]; -} - -export interface FinanceDecision { - plan: FinanceEvidencePlan; - marketRoute: FinanceMarketRoute; - confidencePenalty: FinanceConfidencePenalty; - reasons: string[]; - score: Record; -} - -const FINANCE_KEYWORDS = [ - "stock", - "stocks", - "equity", - "equities", - "valuation", - "financial", - "finance", - "earnings", - "revenue", - "eps", - "cash flow", - "balance sheet", - "income statement", - "pe ratio", - "market cap", - "ipo", - "pre-ipo", - "listing", - "ticker", - "一级市场", - "二级市场", - "财报", - "股票", - "估值", - "市值", - "募资", - "锁定期", - "稀释", -]; - -const PRIMARY_MARKET_KEYWORDS = [ - "ipo", - "pre-ipo", - "prospectus", - "s-1", - "f-1", - "roadshow", - "listing", - "follow-on", - "new issuance", - "lock-up", - "dilution", - "一级市场", - "募资", - "锁定期", - "稀释", -]; - -const EVENT_DRIVEN_KEYWORDS = [ - "latest", - "recent", - "today", - "yesterday", - "breaking", - "earnings call", - "guidance", - "surprise", - "selloff", - "policy", - "fed", - "fomc", - "news", - "headline", - "突发", - "最新", - "消息", - "政策", - "财报后", -]; - -const CAUSAL_KEYWORDS = [ - "why", - "reason", - "driver", - "impact", - "because", - "attribution", - "explain", - "原因", - "驱动", - "影响", - "为什么", -]; - -const TIME_SENSITIVE_KEYWORDS = [ - "latest", - "today", - "this week", - "this month", - "current", - "now", - "最新", - "当前", - "近期", -]; - -const COMMON_UPPERCASE_NON_TICKERS = new Set([ - "IPO", - "SEC", - "USD", - "CNY", - "HKD", - "GDP", - "CPI", - "PPI", - "FED", - "FOMC", - "EPS", - "FCF", - "PE", - "TTM", - "DCF", -]); - -function includesAny(text: string, keywords: string[]): boolean { - return keywords.some((keyword) => text.includes(keyword)); -} - -function normalizeTools(tools: string[]): Set { - return new Set(tools.map((tool) => tool.toLowerCase())); -} - -function hasTickerSignal(prompt: string): boolean { - const explicit = /(?:\$|ticker\s*[:=]\s*)([A-Za-z]{1,6})/g; - if (explicit.test(prompt)) return true; - - const upperWords = prompt.match(/\b[A-Z]{1,6}\b/g) ?? []; - const candidates = upperWords.filter((word) => !COMMON_UPPERCASE_NON_TICKERS.has(word)); - return candidates.length > 0; -} - -function isFinanceTask(prompt: string): boolean { - const normalized = prompt.toLowerCase(); - return includesAny(normalized, FINANCE_KEYWORDS); -} - -function resolveMarketRoute(prompt: string): FinanceMarketRoute { - const normalized = prompt.toLowerCase(); - const primary = includesAny(normalized, PRIMARY_MARKET_KEYWORDS); - if (!primary) return "secondary"; - return hasTickerSignal(prompt) ? "primary_with_ticker" : "primary_no_ticker"; -} - -function choosePlan(score: Record): FinanceEvidencePlan { - const order: FinanceEvidencePlan[] = ["data_only", "hybrid", "web_first"]; - let best: FinanceEvidencePlan = order[0]; - for (const plan of order) { - if (score[plan] > score[best]) best = plan; - } - return best; -} - -function resolveConfidencePenalty(params: { - plan: FinanceEvidencePlan; - hasData: boolean; - hasWeb: boolean; - route: FinanceMarketRoute; - eventDriven: boolean; - timeSensitive: boolean; -}): FinanceConfidencePenalty { - const { plan, hasData, hasWeb, route, eventDriven, timeSensitive } = params; - - if (!hasData && !hasWeb) return "high"; - if ((plan === "hybrid" || plan === "web_first") && !hasWeb) return "high"; - if (plan === "data_only" && (eventDriven || timeSensitive) && !hasWeb) return "high"; - if (route === "primary_no_ticker") return "medium"; - if (plan === "data_only" && (eventDriven || timeSensitive)) return "medium"; - return "low"; -} - -export function decideFinanceEvidencePlan(input: FinanceDecisionInput): FinanceDecision | undefined { - const { prompt } = input; - if (!isFinanceTask(prompt)) return undefined; - - const normalized = prompt.toLowerCase(); - const toolSet = normalizeTools(input.tools); - const hasData = toolSet.has("data"); - const hasWebSearch = toolSet.has("web_search"); - const hasWebFetch = toolSet.has("web_fetch"); - const hasWeb = hasWebSearch || hasWebFetch; - - const route = resolveMarketRoute(prompt); - const eventDriven = includesAny(normalized, EVENT_DRIVEN_KEYWORDS); - const causal = includesAny(normalized, CAUSAL_KEYWORDS); - const timeSensitive = includesAny(normalized, TIME_SENSITIVE_KEYWORDS); - - const score: Record = { - data_only: hasData ? 1.0 : -3.0, - hybrid: hasData && hasWeb ? 1.0 : -2.0, - web_first: hasWeb ? 0.6 : -3.0, - }; - - const reasons: string[] = []; - - if (route === "secondary") { - score.data_only += 0.7; - score.hybrid += 0.4; - reasons.push("secondary_market_task"); - } else if (route === "primary_with_ticker") { - score.hybrid += 0.9; - score.web_first += 0.3; - score.data_only -= 0.2; - reasons.push("primary_market_with_ticker"); - } else { - score.web_first += 1.3; - score.hybrid += 0.7; - score.data_only -= 1.0; - reasons.push("primary_market_without_ticker"); - } - - if (eventDriven) { - score.hybrid += 0.9; - score.web_first += 0.4; - score.data_only -= 0.5; - reasons.push("event_driven"); - } - - if (timeSensitive) { - score.hybrid += 0.6; - score.web_first += 0.3; - score.data_only -= 0.4; - reasons.push("time_sensitive"); - } - - if (causal) { - score.hybrid += 0.4; - score.web_first += 0.2; - score.data_only -= 0.2; - reasons.push("causal_explanation_needed"); - } - - if (!hasWeb) { - score.hybrid -= 2.0; - score.web_first -= 3.0; - reasons.push("web_tools_unavailable"); - } - if (!hasData) { - score.data_only -= 2.5; - score.hybrid -= 1.5; - score.web_first += 0.5; - reasons.push("data_tool_unavailable"); - } - - const plan = choosePlan(score); - const confidencePenalty = resolveConfidencePenalty({ - plan, - hasData, - hasWeb, - route, - eventDriven, - timeSensitive, - }); - - return { - plan, - marketRoute: route, - confidencePenalty, - reasons, - score, - }; -} - -export function buildInternalFinanceGuidance(decision: FinanceDecision): string { - return [ - "## Internal Finance Research Guidance", - "This section is internal orchestration guidance. Do not expose technical labels directly to the user unless they explicitly request methodology details.", - `Preferred evidence plan: ${decision.plan}`, - `Market route: ${decision.marketRoute}`, - `Confidence penalty if evidence gaps remain: ${decision.confidencePenalty}`, - `Decision factors: ${decision.reasons.join(", ") || "none"}`, - "Execution policy: start with the preferred plan, then escalate evidence collection if signals conflict or causality remains unresolved.", - ].join("\n"); -} diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 8ea675f3..beaa4faf 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -35,12 +35,8 @@ import { import { buildSystemPrompt as buildStructuredSystemPrompt, collectRuntimeInfo, + type SystemPromptMode, } from "./system-prompt/index.js"; -import { - buildInternalFinanceGuidance, - decideFinanceEvidencePlan, - type FinanceDecision, -} from "./research/finance-decisioner.js"; import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; import { sanitizeToolCallInputs, @@ -429,7 +425,6 @@ export class Agent { ): Promise { await this.ensureInitialized(); this.refreshAuthState(); - this.applyFinanceResearchGuidance(prompt); this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; @@ -973,13 +968,6 @@ export class Agent { * Shared by constructor (via buildFullSystemPrompt) and reloadSystemPrompt. */ private rebuildSystemPrompt(toolNames: string[]): string | undefined { - return this.rebuildSystemPromptWithExtra(toolNames); - } - - private rebuildSystemPromptWithExtra( - toolNames: string[], - extraSystemPrompt?: string | undefined, - ): string | undefined { const profile = this.profile?.getProfile(); if (!profile) return undefined; @@ -1005,40 +993,6 @@ export class Agent { tools: toolNames, skillsPrompt, runtime, - extraSystemPrompt, - }); - } - - private applyFinanceResearchGuidance(prompt: string): void { - const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name); - const decision = decideFinanceEvidencePlan({ prompt, tools: toolNames }); - - const guidance = decision ? buildInternalFinanceGuidance(decision) : undefined; - const systemPrompt = this.rebuildSystemPromptWithExtra(toolNames, guidance); - if (systemPrompt) { - this.agent.setSystemPrompt(systemPrompt); - this.session.setSystemPrompt(systemPrompt); - } - - this.saveFinanceDecisionMeta(decision); - } - - private saveFinanceDecisionMeta(decision: FinanceDecision | undefined): void { - const currentMeta = this.session.getMeta() ?? {}; - if (!decision) { - // Keep previously saved decisions for non-finance turns. - return; - } - this.session.saveMeta({ - ...currentMeta, - researchDecision: { - domain: "finance", - plan: decision.plan, - marketRoute: decision.marketRoute, - confidencePenalty: decision.confidencePenalty, - reasons: decision.reasons, - timestamp: Date.now(), - }, }); } } diff --git a/packages/core/src/agent/session/types.ts b/packages/core/src/agent/session/types.ts index 48c037a9..ec734424 100644 --- a/packages/core/src/agent/session/types.ts +++ b/packages/core/src/agent/session/types.ts @@ -9,15 +9,6 @@ export type SessionMeta = { reasoningMode?: string; /** Context window token 数 */ contextWindowTokens?: number; - /** Internal finance evidence decision from the latest run */ - researchDecision?: { - domain: "finance"; - plan: "data_only" | "hybrid" | "web_first"; - marketRoute: "secondary" | "primary_with_ticker" | "primary_no_ticker"; - confidencePenalty: "low" | "medium" | "high"; - reasons: string[]; - timestamp: number; - } | undefined; }; export type SessionEntry = From 24435cdba4f2fa7519c2272fc29691ec6604fd39 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 20:31:40 +0800 Subject: [PATCH 29/30] refactor(agent): remove hasWebTools conditional in data section Web tools are always available, so the conditional branching was unnecessary. Data section now always includes the dynamic evidence decision guidance. Co-Authored-By: Claude Opus 4.6 --- .../src/agent/system-prompt/sections.test.ts | 4 ++-- .../core/src/agent/system-prompt/sections.ts | 18 ++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index 00549efe..df446356 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -181,8 +181,8 @@ describe("buildConditionalToolSections", () => { expect(text).toContain("Web usage is conditional, not mandatory"); }); - it("adds dynamic evidence decision guidance when both data and web tools are present", () => { - const result = buildConditionalToolSections(["data", "web_search"], "full"); + it("adds dynamic evidence decision guidance when data tool is present", () => { + const result = buildConditionalToolSections(["data"], "full"); const text = result.join("\n"); expect(text).toContain("## Data Access"); expect(text).toContain("dynamic evidence decision"); diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 52809aad..ce8bcc67 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -240,7 +240,6 @@ export function buildConditionalToolSections( if (mode === "none" || !tools || tools.length === 0) return []; const toolSet = new Set(tools.map((t) => t.toLowerCase())); - const hasWebTools = toolSet.has("web_search") || toolSet.has("web_fetch"); const lines: string[] = []; // Memory tools @@ -271,28 +270,19 @@ export function buildConditionalToolSections( // Data tools if (toolSet.has("data")) { - const dataLines = [ + lines.push( "## Data Access", "You have access to structured financial and market data via the `data` tool.", 'Use domain="finance" with specific actions to retrieve stock prices, financial statements, SEC filings, metrics, and more.', "Always specify dates in YYYY-MM-DD format. Use period='annual' or 'quarterly' or 'ttm' for financial statements.", - hasWebTools - ? "When both data and web tools are available, make a dynamic evidence decision: start from structured data, and use web tools only when external validation is needed (for example: event-driven, time-sensitive, or conflicting/incomplete evidence)." - : "Use tool outputs as evidence, and clearly state assumptions when data is incomplete.", - ...(hasWebTools - ? [ - "Make this evidence decision internally. In final answers, present concise user-facing research rationale instead of technical decision labels unless the user asks for methodology details.", - ] - : []), + "When both data and web tools are available, make a dynamic evidence decision: start from structured data, and use web tools only when external validation is needed (for example: event-driven, time-sensitive, or conflicting/incomplete evidence).", + "Make this evidence decision internally. In final answers, present concise user-facing research rationale instead of technical decision labels unless the user asks for methodology details.", "", - ]; - lines.push( - ...dataLines, ); } // Web tools - if (hasWebTools) { + if (toolSet.has("web_search") || toolSet.has("web_fetch")) { lines.push( "## Web Access", "You have web access. Use it when the user asks about current events, needs up-to-date information, or requests content from URLs.", From 953041efa09acc4e1dbd5dc6d11e11548a5c9d27 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 11 Feb 2026 20:57:52 +0800 Subject: [PATCH 30/30] feat(ui): add LaTeX math rendering support to chat markdown Add remark-math + rehype-katex plugins to render inline ($...$) and display ($$...$$) math expressions. Includes dark mode CSS overrides, streaming block splitting for math fences, and math range exclusion in link preprocessing. Co-Authored-By: Claude Opus 4.6 --- packages/ui/package.json | 7 +- .../ui/src/components/markdown/Markdown.tsx | 7 +- .../components/markdown/StreamingMarkdown.tsx | 25 ++- .../ui/src/components/markdown/linkify.ts | 26 +++- packages/ui/src/styles/globals.css | 13 ++ pnpm-lock.yaml | 145 ++++++++++++++++++ 6 files changed, 215 insertions(+), 8 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index a2c2c59f..41fec451 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "sideEffects": ["**/*.css"], + "sideEffects": [ + "**/*.css" + ], "scripts": { "typecheck": "tsc --noEmit", "lint": "eslint src" @@ -29,14 +31,17 @@ "@tiptap/starter-kit": "^3.19.0", "class-variance-authority": "catalog:", "clsx": "catalog:", + "katex": "^0.16.28", "linkify-it": "^5.0.0", "next-themes": "^0.4.6", "qr-scanner": "^1.4.2", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "shadcn": "^3.7.0", "shiki": "^3.21.0", "sonner": "^2.0.7", diff --git a/packages/ui/src/components/markdown/Markdown.tsx b/packages/ui/src/components/markdown/Markdown.tsx index 52e22ddf..d201b9c1 100644 --- a/packages/ui/src/components/markdown/Markdown.tsx +++ b/packages/ui/src/components/markdown/Markdown.tsx @@ -1,10 +1,13 @@ import * as React from 'react' import ReactMarkdown, { type Components } from 'react-markdown' +import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' import { cn } from '@multica/ui/lib/utils' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' +import 'katex/dist/katex.min.css' /** * Render modes for markdown content: @@ -270,8 +273,8 @@ export function Markdown({ return (
{processedContent} diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx index fc50963f..86e2b72b 100644 --- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx +++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx @@ -49,6 +49,7 @@ function splitIntoBlocks(content: string): Block[] { const lines = content.split('\n') let currentBlock = '' let inCodeBlock = false + let inMathBlock = false for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? '' @@ -73,6 +74,26 @@ function splitIntoBlocks(content: string): Block[] { } else if (inCodeBlock) { // Inside code block - append line currentBlock += line + '\n' + // Check for display math fence ($$) + } else if (line.trim() === '$$') { + if (!inMathBlock) { + // Starting a math block - flush current paragraph first + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + inMathBlock = true + currentBlock = line + '\n' + } else { + // Ending a math block + currentBlock += line + blocks.push({ content: currentBlock, isCodeBlock: false }) + currentBlock = '' + inMathBlock = false + } + } else if (inMathBlock) { + // Inside math block - append line (don't split on blank lines) + currentBlock += line + '\n' } else if (line === '') { // Empty line outside code block = paragraph boundary if (currentBlock.trim()) { @@ -92,8 +113,8 @@ function splitIntoBlocks(content: string): Block[] { // Flush remaining content if (currentBlock) { blocks.push({ - content: inCodeBlock ? currentBlock : currentBlock.trim(), - isCodeBlock: inCodeBlock // Unclosed code block = still streaming + content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(), + isCodeBlock: inCodeBlock }) } diff --git a/packages/ui/src/components/markdown/linkify.ts b/packages/ui/src/components/markdown/linkify.ts index ed06a9b7..f4ee1809 100644 --- a/packages/ui/src/components/markdown/linkify.ts +++ b/packages/ui/src/components/markdown/linkify.ts @@ -42,14 +42,34 @@ function findCodeRanges(text: string): CodeRange[] { ranges.push({ start: match.index, end: match.index + match[0].length }) } + // Find display math blocks ($$...$$) + const displayMathRegex = /\$\$[\s\S]*?\$\$/g + while ((match = displayMathRegex.exec(text)) !== null) { + const pos = match.index + const insideOther = ranges.some((r) => pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + // Find inline math ($...$) + const inlineMathRegex = /(? pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + // Find inline code (`...`) // But skip escaped backticks and code inside fenced blocks const inlineRegex = /(? pos >= r.start && pos < r.end) - if (!insideFenced) { + // Check if this is inside a fenced block or math block + const insideOther = ranges.some((r) => pos >= r.start && pos < r.end) + if (!insideOther) { ranges.push({ start: pos, end: pos + match[0].length }) } } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index c7ea571b..ddcef476 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -143,6 +143,19 @@ color: var(--shiki-dark) !important; } +/* KaTeX math: inherit text color for light/dark theme support */ +.katex { + color: inherit; + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + overflow-x: auto; + overflow-y: hidden; + padding: 0.5em 0; +} + /* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */ .mask-fade-y { mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b7bad9..288cdcae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,6 +761,9 @@ importers: clsx: specifier: 'catalog:' version: 2.1.1 + katex: + specifier: ^0.16.28 + version: 0.16.28 linkify-it: specifier: ^5.0.0 version: 5.0.0 @@ -779,12 +782,18 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.13)(react@19.2.3) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 rehype-raw: specifier: ^7.0.0 version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 shadcn: specifier: ^3.7.0 version: 3.8.4(@types/node@25.2.2)(typescript@5.9.3) @@ -4006,6 +4015,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -5019,6 +5031,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -6361,9 +6377,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -6379,6 +6407,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -6936,6 +6967,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7364,6 +7399,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -7498,6 +7536,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -8717,12 +8758,18 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -9720,12 +9767,18 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -13990,6 +14043,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 25.2.2 @@ -15153,6 +15208,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -16944,6 +17001,28 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -16955,6 +17034,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -17019,6 +17102,13 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -17577,6 +17667,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.16.28: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -17977,6 +18071,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -18319,6 +18425,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.28 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -19806,6 +19922,16 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.28 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -19823,6 +19949,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -21002,6 +21137,11 @@ snapshots: dependencies: crypto-random-string: 2.0.0 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -21010,6 +21150,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3