chore: establish YCode replication baseline and TODO

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

No files matched your search

+447
View File
@@ -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
}
}