chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
+447
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* 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 } from 'node:path'
|
||||
|
||||
/** 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 可执行文件 */
|
||||
export function detectOmpPath(): string | null {
|
||||
const candidates = [
|
||||
process.env['YCODE_OMP_PATH'],
|
||||
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 {
|
||||
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.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): 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 ? { isError: true } : {})
|
||||
}
|
||||
child.stdin.write(`${JSON.stringify(payload)}\n`)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user