481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
/**
|
||
* OMP 内核 sidecar(`omp --mode rpc`)。
|
||
*
|
||
* OMP RPC uses newline-delimited JSON frames over stdio:
|
||
* - stdio 上的 **newline-delimited JSONL**(不是 JSON-RPC 2.0)
|
||
* - 启动后先写一个 `ready` 帧
|
||
* - stdin:命令(可带 `id` 用于关联);stdout:响应 + 会话事件
|
||
* - 单帧上限 1 MiB
|
||
*
|
||
* 持久会话使用 OMP 自己的 JSONL transcript;未提供 sessionPath 时保持临时模式。
|
||
*/
|
||
|
||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||
import { createInterface } from 'node:readline'
|
||
import { EventEmitter } from 'node:events'
|
||
import { existsSync, readFileSync } from 'node:fs'
|
||
import { mkdir } from 'node:fs/promises'
|
||
import { homedir } from 'node:os'
|
||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
/** OMP 单帧上限(含换行) */
|
||
const MAX_FRAME_BYTES = 1024 * 1024
|
||
/** 命令默认超时 */
|
||
const COMMAND_TIMEOUT_MS = 60_000
|
||
|
||
export interface OmpFrame<TData = unknown> {
|
||
type: string
|
||
id?: string
|
||
command?: string
|
||
success?: boolean
|
||
data?: TData
|
||
error?: string | { message?: string }
|
||
[key: string]: unknown
|
||
}
|
||
|
||
export interface OmpAgentMessage {
|
||
role: string
|
||
content?: unknown[]
|
||
[key: string]: unknown
|
||
}
|
||
|
||
export interface OmpSessionTreeEntry {
|
||
id: string
|
||
parentId: string | null
|
||
type: string
|
||
message?: OmpAgentMessage
|
||
[key: string]: unknown
|
||
}
|
||
|
||
export interface OmpSessionTreeNode {
|
||
entry: OmpSessionTreeEntry
|
||
children: OmpSessionTreeNode[]
|
||
label?: string
|
||
}
|
||
|
||
export interface OmpBranchMessage {
|
||
entryId: string
|
||
text: string
|
||
}
|
||
|
||
export interface OmpHostToolDefinition {
|
||
name: string
|
||
label?: string
|
||
description: string
|
||
parameters: Record<string, unknown>
|
||
}
|
||
|
||
export interface OmpToolInfo {
|
||
id?: string
|
||
name?: string
|
||
/** Optional human-readable action text emitted alongside the native tool call. */
|
||
intent?: string
|
||
argsRaw?: string
|
||
/** 部分输出 / 最终输出(尽力解析) */
|
||
output?: string
|
||
ok?: boolean
|
||
isError?: boolean
|
||
}
|
||
|
||
export interface OmpPromptImage {
|
||
type: 'image'
|
||
data: string
|
||
mimeType: string
|
||
}
|
||
|
||
interface Pending {
|
||
resolve: (value: OmpFrame) => void
|
||
reject: (err: Error) => void
|
||
timer: NodeJS.Timeout
|
||
}
|
||
|
||
/**
|
||
* 随包内置的 OMP:打包后位于 resources/omp,开发态位于项目 resources/omp。
|
||
* 版本与摘要由 resources/omp/runtime.lock.json 锁定(npm run omp:verify 校验)。
|
||
*/
|
||
function detectBundledOmpPath(): string | null {
|
||
let moduleBundled: string | null = null
|
||
if (typeof __dirname === 'string' && __dirname) {
|
||
moduleBundled = resolve(__dirname, '..', '..', 'resources', 'omp', 'omp.exe')
|
||
} else if (typeof import.meta.url === 'string') {
|
||
try {
|
||
moduleBundled = fileURLToPath(new URL('../../resources/omp/omp.exe', import.meta.url))
|
||
} catch {
|
||
// CJS test bundles may expose an empty import.meta object.
|
||
}
|
||
}
|
||
const candidates = [
|
||
process.resourcesPath ? join(process.resourcesPath, 'omp', 'omp.exe') : null,
|
||
join(process.cwd(), 'resources', 'omp', 'omp.exe'),
|
||
moduleBundled
|
||
]
|
||
for (const candidate of candidates) {
|
||
if (candidate && existsSync(candidate)) return candidate
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 探测 OMP 可执行文件:显式覆盖 > 随包内置 > 本机 Ygent 安装 */
|
||
export function detectOmpPath(): string | null {
|
||
const candidates = [
|
||
process.env['YCODE_OMP_PATH'],
|
||
detectBundledOmpPath(),
|
||
process.env['LOCALAPPDATA'] ? `${process.env['LOCALAPPDATA']}\\Ygent\\omp.exe` : null,
|
||
'C:\\Program Files\\Ygent\\omp.exe'
|
||
].filter((p): p is string => !!p)
|
||
for (const p of candidates) {
|
||
if (existsSync(p)) return p
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** OMP 内核的对外信息(设置页 / 输入框展示用) */
|
||
export interface OmpInfo {
|
||
available: boolean
|
||
exePath: string | null
|
||
profile: string
|
||
/** 从 profile 的 models.yml 读出的首个自定义模型(`<provider>/<id>` 形式),读不到为 null */
|
||
model: string | null
|
||
}
|
||
|
||
/** Returns the first custom model with its OMP provider namespace. */
|
||
export function parseFirstProfileModel(modelsYaml: string): string | null {
|
||
let inProviders = false
|
||
let providerId: string | null = null
|
||
let inModels = false
|
||
|
||
for (const line of modelsYaml.split(/\r?\n/)) {
|
||
if (!line.trim() || line.trimStart().startsWith('#')) continue
|
||
const indent = line.length - line.trimStart().length
|
||
const content = line.trimStart()
|
||
|
||
if (indent === 0) {
|
||
inProviders = /^providers:\s*(?:#.*)?$/.test(content)
|
||
providerId = null
|
||
inModels = false
|
||
continue
|
||
}
|
||
if (!inProviders) continue
|
||
|
||
if (indent === 2) {
|
||
const provider = content.match(/^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9._-]+)):\s*(?:#.*)?$/)
|
||
providerId = provider?.[1] ?? provider?.[2] ?? provider?.[3] ?? null
|
||
inModels = false
|
||
continue
|
||
}
|
||
|
||
if (!providerId) continue
|
||
if (indent === 4) {
|
||
inModels = /^models:\s*(?:#.*)?$/.test(content)
|
||
continue
|
||
}
|
||
if (!inModels || indent < 6) continue
|
||
|
||
const model = content.match(/^[-]\s*id:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))\s*(?:#.*)?$/)
|
||
const modelId = model?.[1] ?? model?.[2] ?? model?.[3]
|
||
if (modelId) return `${providerId}/${modelId}`
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/** Reads the executable and first provider/model pair declared in the selected profile. */
|
||
export function getOmpInfo(profile = 'ycode'): OmpInfo {
|
||
const exePath = detectOmpPath()
|
||
if (!exePath) {
|
||
return { available: false, exePath: null, profile, model: null }
|
||
}
|
||
const modelsPath = join(homedir(), '.omp', 'profiles', profile, 'agent', 'models.yml')
|
||
let model: string | null = null
|
||
try {
|
||
const text = readFileSync(modelsPath, 'utf8')
|
||
model = parseFirstProfileModel(text)
|
||
} catch {
|
||
// profile 未配置时只暴露可用性
|
||
}
|
||
return { available: true, exePath, profile, model }
|
||
}
|
||
|
||
export class OmpKernel extends EventEmitter {
|
||
get isStarted(): boolean { return this.readyDone && !this.disposed }
|
||
private child: ChildProcessWithoutNullStreams | null = null
|
||
private pending = new Map<string, Pending>()
|
||
private seq = 0
|
||
private starting: Promise<void> | null = null
|
||
private readyDone = false
|
||
private disposed = false
|
||
private disposal: Promise<void> | null = null
|
||
private startupErrorReported = false
|
||
|
||
constructor(
|
||
private readonly exePath: string,
|
||
private readonly cwd: string,
|
||
private readonly profile = 'ycode',
|
||
private readonly sessionPath?: string,
|
||
private readonly modelOverride?: string | null,
|
||
private readonly environment?: NodeJS.ProcessEnv
|
||
) {
|
||
super()
|
||
}
|
||
|
||
/** 启动进程并等待 ready 帧;重复调用只会启动一次 */
|
||
async start(): Promise<void> {
|
||
if (this.disposed) throw new Error('OMP 内核已释放')
|
||
if (this.readyDone) return
|
||
if (this.starting) return this.starting
|
||
|
||
this.startupErrorReported = false
|
||
const starting = this.startProcess().catch((err: Error) => {
|
||
if (this.starting === starting) this.starting = null
|
||
if (!this.startupErrorReported && this.listenerCount('error') > 0) {
|
||
this.startupErrorReported = true
|
||
this.emit('error', err)
|
||
}
|
||
throw err
|
||
})
|
||
this.starting = starting
|
||
return starting
|
||
}
|
||
|
||
private async startProcess(): Promise<void> {
|
||
if (!existsSync(this.exePath)) {
|
||
throw new Error(`OMP 可执行文件不存在:${this.exePath}`)
|
||
}
|
||
if (this.sessionPath) {
|
||
if (!isAbsolute(this.sessionPath)) throw new Error('OMP 会话路径必须是绝对路径')
|
||
await mkdir(dirname(this.sessionPath), { recursive: true })
|
||
}
|
||
if (this.disposed) throw new Error('OMP 内核已释放')
|
||
|
||
// spawn 的 cwd 必须存在,否则报 ENOENT(与可执行文件无关)
|
||
const cwd = existsSync(this.cwd) ? this.cwd : homedir()
|
||
const model = this.modelOverride === undefined ? getOmpInfo(this.profile).model : this.modelOverride
|
||
if (!model || model.indexOf('/') <= 0 || model.endsWith('/')) {
|
||
throw new Error(`OMP profile "${this.profile}" 缺少有效的 provider/model;请在 agent/models.yml 中配置模型`)
|
||
}
|
||
|
||
const args = ['--mode', 'rpc', '--profile', this.profile, `--model=${model}`, '--cwd', cwd, '--no-tools']
|
||
if (this.environment?.YCODE_MODEL_CONFIG) args.push('--config', this.environment.YCODE_MODEL_CONFIG)
|
||
if (this.environment?.YCODE_CONTEXT_OBSERVER_EXTENSION) args.push('--extension',this.environment.YCODE_CONTEXT_OBSERVER_EXTENSION)
|
||
if (this.sessionPath) {
|
||
args.push(`--session-dir=${dirname(this.sessionPath)}`, `--resume=${this.sessionPath}`)
|
||
} else {
|
||
args.push('--no-session')
|
||
}
|
||
|
||
const child = spawn(this.exePath, args, { cwd, windowsHide: true, env: { ...process.env, ...this.environment } })
|
||
this.child = child
|
||
const rl = createInterface({ input: child.stdout })
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
let settled = false
|
||
let startupTimer: NodeJS.Timeout | undefined
|
||
const settle = (err?: Error): void => {
|
||
if (settled) return
|
||
settled = true
|
||
if (startupTimer) clearTimeout(startupTimer)
|
||
this.off('frame', onReady)
|
||
if (err) reject(err)
|
||
else resolve()
|
||
}
|
||
|
||
const onReady = (frame: OmpFrame): void => {
|
||
if (frame.type !== 'ready') return
|
||
this.readyDone = true
|
||
settle()
|
||
}
|
||
|
||
child.on('error', (err) => {
|
||
this.startupErrorReported = true
|
||
if (this.listenerCount('error') > 0) this.emit('error', err)
|
||
settle(err)
|
||
})
|
||
child.on('exit', (code) => {
|
||
const wasReady = this.readyDone
|
||
this.emit('exit', code)
|
||
rl.close()
|
||
for (const [id, pending] of this.pending) {
|
||
clearTimeout(pending.timer)
|
||
pending.reject(new Error(`OMP 进程已退出(code=${code}),命令 ${id} 未完成`))
|
||
}
|
||
this.pending.clear()
|
||
if (this.child === child) this.child = null
|
||
this.readyDone = false
|
||
this.starting = null
|
||
if (!wasReady) settle(new Error(`OMP 进程在 ready 前退出(code=${code})`))
|
||
})
|
||
child.stderr.on('data', (chunk: Buffer) => {
|
||
this.emit('stderr', chunk.toString())
|
||
})
|
||
|
||
rl.on('line', (line) => this.handleLine(line))
|
||
this.on('frame', onReady)
|
||
startupTimer = setTimeout(() => settle(new Error('OMP 启动超时(未收到 ready 帧)')), 30_000)
|
||
startupTimer.unref()
|
||
})
|
||
}
|
||
|
||
private handleLine(line: string): void {
|
||
const trimmed = line.trim()
|
||
if (!trimmed) return
|
||
if (trimmed.length > MAX_FRAME_BYTES) {
|
||
this.emit('error', new Error(`OMP 帧超过上限(${trimmed.length} 字节)`))
|
||
return
|
||
}
|
||
let frame: OmpFrame
|
||
try {
|
||
frame = JSON.parse(trimmed) as OmpFrame
|
||
} catch {
|
||
this.emit('error', new Error(`OMP 输出无法解析:${trimmed.slice(0, 200)}`))
|
||
return
|
||
}
|
||
|
||
// 带响应 id 的帧 → 关联到挂起命令
|
||
if (frame.id && this.pending.has(frame.id)) {
|
||
const p = this.pending.get(frame.id)
|
||
if (p) {
|
||
this.pending.delete(frame.id)
|
||
clearTimeout(p.timer)
|
||
p.resolve(frame)
|
||
}
|
||
// 响应帧同时也广播(prompt 的 ack 可能带 agentInvoked 信息)
|
||
this.emit('frame', frame)
|
||
return
|
||
}
|
||
|
||
this.emit('frame', frame)
|
||
this.emit('event', frame)
|
||
}
|
||
|
||
/** 发送一条命令并等待其响应 */
|
||
async send(command: Record<string, unknown>, timeoutMs = COMMAND_TIMEOUT_MS): Promise<OmpFrame> {
|
||
await this.start()
|
||
const id = `req_${++this.seq}`
|
||
const payload = JSON.stringify({ id, ...command })
|
||
|
||
return new Promise<OmpFrame>((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
this.pending.delete(id)
|
||
reject(new Error(`OMP 命令超时:${String(command.type)}`))
|
||
}, timeoutMs)
|
||
timer.unref?.()
|
||
|
||
this.pending.set(id, { resolve, reject, timer })
|
||
this.child?.stdin.write(payload + '\n')
|
||
})
|
||
}
|
||
|
||
/** 发起一次对话回合;事件经 `event` 回调流出 */
|
||
async prompt(message: string, images?: OmpPromptImage[]): Promise<OmpFrame> {
|
||
const command: Record<string, unknown> = { type: 'prompt', message }
|
||
if (images?.length) command.images = images
|
||
return this.send(command, COMMAND_TIMEOUT_MS * 10)
|
||
}
|
||
|
||
async abort(): Promise<OmpFrame> {
|
||
return this.send({ type: 'abort' }, 15_000)
|
||
}
|
||
|
||
async getMessages(): Promise<OmpFrame<{ messages: OmpAgentMessage[] }>> {
|
||
return this.send({ type: 'get_messages' }) as Promise<OmpFrame<{ messages: OmpAgentMessage[] }>>
|
||
}
|
||
|
||
async getTree(): Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>> {
|
||
return this.send({ type: 'get_tree' }) as Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>>
|
||
}
|
||
|
||
async getBranchMessages(): Promise<OmpFrame<{ messages: OmpBranchMessage[] }>> {
|
||
return this.send({ type: 'get_branch_messages' }) as Promise<OmpFrame<{ messages: OmpBranchMessage[] }>>
|
||
}
|
||
|
||
async branch(entryId: string): Promise<OmpFrame<{ text?: string; cancelled?: boolean }>> {
|
||
if (!entryId.trim()) throw new Error('OMP branch requires a native user message entryId')
|
||
return this.send({ type: 'branch', entryId }) as Promise<OmpFrame<{ text?: string; cancelled?: boolean }>>
|
||
}
|
||
|
||
async setHostTools(tools: OmpHostToolDefinition[]): Promise<OmpFrame<{ toolNames: string[] }>> {
|
||
return this.send({ type: 'set_host_tools', tools }) as Promise<OmpFrame<{ toolNames: string[] }>>
|
||
}
|
||
|
||
/** Levels come from `getAvailableThinkingLevels()`; `off` disables thinking for this session. */
|
||
async setThinkingLevel(level: string): Promise<void> {
|
||
await this.send({ type: 'set_thinking_level', level })
|
||
}
|
||
|
||
async getAvailableThinkingLevels(): Promise<string[]> {
|
||
const frame = await this.send({ type: 'get_available_thinking_levels' }) as OmpFrame<{ levels?: string[] }>
|
||
return Array.isArray(frame.levels) ? frame.levels : []
|
||
}
|
||
|
||
/** Resolve an OMP host_tool_call. The host request id, not toolCallId, correlates the reply. */
|
||
async respondToHostTool(requestId: string, content: string, isError = false, images?: OmpPromptImage[]): Promise<void> {
|
||
await this.start()
|
||
if (!requestId.trim()) throw new Error('OMP host tool response requires a request id')
|
||
const child = this.child
|
||
if (!child) throw new Error('OMP sidecar is not running')
|
||
const payload = {
|
||
type: 'host_tool_result',
|
||
id: requestId,
|
||
result: { content: [{ type: 'text', text: content }, ...(!isError ? images ?? [] : [])] },
|
||
...(isError ? { isError: true } : {})
|
||
}
|
||
const frame = `${JSON.stringify(payload)}\n`
|
||
if (Buffer.byteLength(frame) > MAX_FRAME_BYTES) throw new Error('OMP 工具结果超过单帧大小限制')
|
||
child.stdin.write(frame)
|
||
}
|
||
|
||
dispose(): Promise<void> {
|
||
if (this.disposal) return this.disposal
|
||
this.disposed = true
|
||
const child = this.child
|
||
if (!child) {
|
||
this.removeAllListeners()
|
||
this.disposal = Promise.resolve()
|
||
return this.disposal
|
||
}
|
||
|
||
this.disposal = new Promise<void>((resolve) => {
|
||
let completed = false
|
||
let killTimer: NodeJS.Timeout | undefined
|
||
const finish = (): void => {
|
||
if (completed) return
|
||
completed = true
|
||
if (killTimer) clearTimeout(killTimer)
|
||
child.off('exit', finish)
|
||
child.off('close', finish)
|
||
child.off('error', finishOnSpawnError)
|
||
if (this.child === child) this.child = null
|
||
this.removeAllListeners()
|
||
resolve()
|
||
}
|
||
const finishOnSpawnError = (): void => {
|
||
if (child.pid === undefined) finish()
|
||
}
|
||
|
||
if (child.exitCode !== null || child.signalCode !== null) {
|
||
finish()
|
||
return
|
||
}
|
||
child.once('exit', finish)
|
||
child.once('close', finish)
|
||
child.on('error', finishOnSpawnError)
|
||
try {
|
||
child.stdin.end()
|
||
} catch {
|
||
// 进程可能已退出
|
||
}
|
||
killTimer = setTimeout(() => {
|
||
if (child.exitCode !== null || child.signalCode !== null) return
|
||
try {
|
||
child.kill()
|
||
} catch {
|
||
// exit / close 事件仍负责完成释放
|
||
}
|
||
}, 1500)
|
||
killTimer.unref()
|
||
})
|
||
return this.disposal
|
||
}
|
||
}
|