diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts index d8a2e5f0e..f522f9a98 100644 --- a/apps/electron/src/main/lib/agent-orchestrator.ts +++ b/apps/electron/src/main/lib/agent-orchestrator.ts @@ -51,7 +51,7 @@ import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getSdkConfigDir, g import { getRuntimeStatus } from './runtime-init' import { getSettings } from './settings-service' import { buildSystemPrompt, buildDynamicContext } from './agent-prompt-builder' -import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt } from './agent-session-context-prompt' +import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt, mergeSessionCleanerSkillMention } from './agent-session-context-prompt' import { permissionService } from './agent-permission-service' import type { PermissionResult, CanUseToolOptions } from './agent-permission-service' import { askUserService } from './agent-ask-user-service' @@ -1623,6 +1623,9 @@ export class AgentOrchestrator { }) } const piCustomTools = [...piBuiltinTools, ...piMcpTools] + const piSkillMentions = agentRuntime === 'pi' + ? mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds) + : mentionedSkills const queryOptions: ClaudeAgentQueryOptions | PiAgentQueryOptions = agentRuntime === 'pi' ? { agentRuntime: 'pi', sessionId, @@ -1647,7 +1650,7 @@ export class AgentOrchestrator { piSessionDir: join(getSdkConfigDir(), 'sessions'), ...(allAdditionalDirectories.length > 0 && { additionalDirectories: allAdditionalDirectories }), ...(workspaceSlug ? { additionalSkillPaths: [getWorkspaceSkillsDir(workspaceSlug)] } : {}), - ...(mentionedSkills?.length ? { skillMentions: mentionedSkills } : {}), + ...(piSkillMentions?.length ? { skillMentions: piSkillMentions } : {}), ...(isCompactCommand ? { compactRequest: true } : {}), ...(sessionMeta?.codexFastMode && channel.provider === 'openai-codex' ? { codexFastMode: true } : {}), ...(codexOAuthCredentials && { @@ -2677,6 +2680,7 @@ export class AgentOrchestrator { enrichedText = `\n${toolLines.join('\n')}\n\n\n${enrichedText}` } + const skillMentions = mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds) const uuid = presetUuid || randomUUID() // 防重记录 @@ -2706,7 +2710,9 @@ export class AgentOrchestrator { } } - await this.adapter.sendQueuedMessage(sessionId, sdkMessage) + await this.adapter.sendQueuedMessage(sessionId, sdkMessage, { + ...(skillMentions?.length ? { skillMentions } : {}), + }) console.log(`[Agent 编排] 追加消息已注入: sessionId=${sessionId}, uuid=${uuid}, interrupt=${!!opts?.interrupt}`) // 立即持久化到 JSONL — 仅存原始文本,不含 prompt 工程块(与 sendMessage 路径一致) diff --git a/apps/electron/src/main/lib/agent-runtime-env.test.ts b/apps/electron/src/main/lib/agent-runtime-env.test.ts index a3cd23f5c..6b2163594 100644 --- a/apps/electron/src/main/lib/agent-runtime-env.test.ts +++ b/apps/electron/src/main/lib/agent-runtime-env.test.ts @@ -88,4 +88,17 @@ describe('Agent Windows Shell 运行环境', () => { expect(result).toEqual({ PATH: 'C:\\Proma;C:\\Windows\\System32' }) }) + + test('Given 打包版 CLI When 构建 Pi Bash 环境 Then 注入 PROMA_CLI 并优先其目录', () => { + const result = buildAgentRuntimeEnv({ + bundledCliPath: '/Applications/Proma.app/Contents/Resources/bin/proma', + platform: 'darwin', + processEnv: { PATH: '/usr/bin:/bin' }, + }) + + expect(result.env).toMatchObject({ + PROMA_CLI: '/Applications/Proma.app/Contents/Resources/bin/proma', + PATH: '/Applications/Proma.app/Contents/Resources/bin:/usr/bin:/bin', + }) + }) }) diff --git a/apps/electron/src/main/lib/agent-session-context-prompt.test.ts b/apps/electron/src/main/lib/agent-session-context-prompt.test.ts new file mode 100644 index 000000000..34f0b928a --- /dev/null +++ b/apps/electron/src/main/lib/agent-session-context-prompt.test.ts @@ -0,0 +1,129 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import { join } from 'node:path' + +type SessionContextPrompt = typeof import('./agent-session-context-prompt') + +let sessionContextPrompt: SessionContextPrompt +let tempHome: string +let resourcesPath: string +const originalHome = process.env.HOME +const originalPromaDev = process.env.PROMA_DEV +const originalResourcesPath = process.resourcesPath + +mock.module('electron', () => ({ + app: { + isPackaged: true, + getPath: () => join(process.env.HOME ?? tempHome, 'Library', 'Application Support'), + getAppPath: () => join(tempHome, 'app'), + }, + BrowserWindow: class {}, + clipboard: {}, + dialog: {}, + nativeImage: { createFromPath: () => ({}) }, + nativeTheme: {}, + powerMonitor: {}, + powerSaveBlocker: {}, + screen: {}, + shell: {}, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf-8'), + }, +})) + +mock.module('node:os', () => ({ + ...os, + homedir: () => tempHome, +})) + +function writeSessionIndex(): void { + const configDir = join(tempHome, '.proma') + mkdirSync(configDir, { recursive: true }) + writeFileSync(join(configDir, 'agent-sessions.json'), JSON.stringify({ + version: 1, + sessions: [ + { id: 'current', title: '当前会话', workspaceId: 'workspace-a', createdAt: 1, updatedAt: 2 }, + { id: 'source', title: '来源 <会话>', workspaceId: 'workspace-a', createdAt: 3, updatedAt: 4 }, + { id: 'large', title: '超大来源会话', workspaceId: 'workspace-a', createdAt: 5, updatedAt: 6 }, + { id: 'other-workspace', title: '不应出现', workspaceId: 'workspace-b', createdAt: 7, updatedAt: 8 }, + ], + }), 'utf-8') +} + +function writeSourceSession(): void { + const sessionsDir = join(tempHome, '.proma', 'agent-sessions') + mkdirSync(sessionsDir, { recursive: true }) + const rows = [ + { type: 'user', message: { content: [{ type: 'text', text: '请检查 的实现' }] }, parent_tool_use_id: null }, + { type: 'assistant', message: { content: [{ type: 'text', text: '已完成检查' }] }, parent_tool_use_id: null }, + ] + writeFileSync(join(sessionsDir, 'source.jsonl'), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf-8') +} + +beforeAll(async () => { + tempHome = mkdtempSync(join(os.tmpdir(), 'proma-session-context-prompt-')) + resourcesPath = join(tempHome, 'resources') + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + writeFileSync(join(resourcesPath, 'bin', 'proma'), '', 'utf-8') + process.env.HOME = tempHome + process.env.PROMA_DEV = '0' + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath }) + writeSessionIndex() + writeSourceSession() + sessionContextPrompt = await import('./agent-session-context-prompt') +}) + +afterAll(() => { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + if (originalPromaDev === undefined) delete process.env.PROMA_DEV + else process.env.PROMA_DEV = originalPromaDev + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: originalResourcesPath }) + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('引用 Agent 会话上下文', () => { + test('Given 同工作区会话被显式引用 When 构建 prompt Then 注入受限地图和明确 CLI 深读命令', () => { + const prompt = sessionContextPrompt.buildReferencedSessionsPrompt( + 'current', + ['source', 'other-workspace'], + 'workspace-a', + 'proma-dev', + ) + + expect(prompt).toContain('') + expect(prompt).toContain('') + expect(prompt).toContain('#0 用户 · 请检查 <untrusted> 的实现') + expect(prompt).toContain('"$PROMA_CLI" session info source') + expect(prompt).toContain('"$PROMA_CLI" session export source --turns A-B') + expect(prompt).toContain('不得只依据标题、元数据或地图回答') + expect(prompt).not.toContain('other-workspace') + }) + + test('Given 引用会话超过地图阈值 When 构建 prompt Then 不同步扫描正文并要求 CLI 深读', () => { + const sessionsDir = join(tempHome, '.proma', 'agent-sessions') + const largeText = 'x'.repeat(2 * 1024 * 1024 + 1) + writeFileSync(join(sessionsDir, 'large.jsonl'), `${JSON.stringify({ + type: 'user', + message: { content: [{ type: 'text', text: largeText }] }, + parent_tool_use_id: null, + })}\n`, 'utf-8') + + const prompt = sessionContextPrompt.buildReferencedSessionsPrompt('current', ['large'], 'workspace-a') + + expect(prompt).toContain(' { + expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], ['source'])) + .toEqual(['automation', 'session-cleaner']) + expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['session-cleaner'], ['source'])) + .toEqual(['session-cleaner']) + expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], [])).toEqual(['automation']) + }) +}) diff --git a/apps/electron/src/main/lib/agent-session-context-prompt.ts b/apps/electron/src/main/lib/agent-session-context-prompt.ts index d301d3121..1e31a7822 100644 --- a/apps/electron/src/main/lib/agent-session-context-prompt.ts +++ b/apps/electron/src/main/lib/agent-session-context-prompt.ts @@ -1,9 +1,18 @@ +import { statSync } from 'node:fs' +import { groupIntoTurns, formatOutlineLine, outline, toTranscript } from '@proma/session-core' +import { readSessionMessages } from '@proma/session-core/node' import { getAgentSessionMeta, getAgentSessionSDKMessages } from './agent-session-manager' -import { getBundledCliPath, getConfigDirName } from './config-paths' +import { getAgentSessionMessagesPath, getBundledCliPath, getConfigDirName, getDevCliEntryPath } from './config-paths' /** 最大回填消息条数 */ export const MAX_CONTEXT_MESSAGES = 20 +/** 显式引用会话时注入的 turn 地图上限,正文仍由 Agent 按需通过 CLI 读取。 */ +const MAX_REFERENCED_SESSION_OUTLINE_TURNS = 16 + +/** 超过此大小的会话只注入读取指引,避免主进程为引用地图同步扫描超大 JSONL。 */ +const MAX_REFERENCED_SESSION_OUTLINE_BYTES = 2 * 1024 * 1024 + /** 单条工具摘要最大字符数 */ const MAX_TOOL_SUMMARY_LENGTH = 200 @@ -16,22 +25,29 @@ function getSessionHistoryPath(sessionId: string): string { return `~/${getConfigDirName()}/agent-sessions/${sessionId}.jsonl` } -function canUseSessionCleaner(): boolean { - return !!getBundledCliPath() -} - function getSessionCleanerSkillName(workspaceSlug?: string): string { return workspaceSlug ? `proma-workspace-${workspaceSlug}:session-cleaner` : 'session-cleaner' } -function getSessionCliCommandPrefix(): string { - return getBundledCliPath() ? '"$PROMA_CLI"' : 'proma' +function quoteShellArgument(value: string): string { + return `"${value.replace(/[$`\\"]/g, '\\$&')}"` +} + +function getSessionCliCommandPrefix(): string | undefined { + if (getBundledCliPath()) return '"$PROMA_CLI"' + + const devCliEntryPath = getDevCliEntryPath() + return devCliEntryPath ? `bun ${quoteShellArgument(devCliEntryPath)} --dev` : undefined } function buildSessionCliAccessGuide(sessionId: string, historyPath: string, workspaceSlug?: string): string { const cli = getSessionCliCommandPrefix() + if (!cli) { + return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}` + } + const skillName = getSessionCleanerSkillName(workspaceSlug) return [ `优先使用 session-cleaner skill(${skillName})读取当前会话历史;它是 Proma CLI 的薄封装,会把 Agent JSONL 清洗为干净对话。`, @@ -46,21 +62,60 @@ function buildSessionCliAccessGuide(sessionId: string, historyPath: string, work } function buildCurrentSessionHistoryInstruction(sessionId: string, workspaceSlug?: string): string { - const historyPath = getSessionHistoryPath(sessionId) - if (canUseSessionCleaner()) { - return buildSessionCliAccessGuide(sessionId, historyPath, workspaceSlug) - } + return buildSessionCliAccessGuide(sessionId, getSessionHistoryPath(sessionId), workspaceSlug) +} + +function buildReferencedSessionOutline(sessionId: string): string | undefined { + try { + const sessionPath = getAgentSessionMessagesPath(sessionId) + const bytes = statSync(sessionPath).size + if (bytes > MAX_REFERENCED_SESSION_OUTLINE_BYTES) { + return [ + ``, + '宿主未扫描超大历史;请先使用 CLI 的 info 和 outline 命令渐进读取。', + '', + ].join('\n') + } - return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}` + const entries = outline(toTranscript(groupIntoTurns(readSessionMessages(sessionPath)))) + const visibleEntries = entries.slice(-MAX_REFERENCED_SESSION_OUTLINE_TURNS) + const omittedEntries = entries.length - visibleEntries.length + const lines = visibleEntries.map((entry) => escapeContextText(formatOutlineLine(entry))) + + return [ + ``, + '以下是宿主生成的受限定位地图,不是完整会话正文;预览可能截断,不能据此声称已读取完整历史。', + ...(omittedEntries > 0 ? [`已省略最早 ${omittedEntries} 个 turn。`] : []), + ...lines, + '', + ].join('\n') + } catch (error) { + console.warn(`[Agent 编排] 无法生成引用会话地图: sessionId=${sessionId}`, error) + return undefined + } } function buildReferencedSessionsHistoryInstruction(workspaceSlug?: string): string { - if (canUseSessionCleaner()) { + const cli = getSessionCliCommandPrefix() + if (cli) { const skillName = getSessionCleanerSkillName(workspaceSlug) - return `需要这些会话的上下文时,优先使用 session-cleaner skill(${skillName})或 Proma CLI 读取清洗后的会话历史。按 info → outline/search → export 的顺序渐进式读取;不要假设会话内容,也不要直接 Read 原始 .jsonl 历史文件。` + return [ + `这些会话已由宿主提供受限 turn 地图,但不含完整正文。若回答、总结、继续执行或决策依赖被引用会话的内容,必须先用 session-cleaner skill(${skillName})或 Proma CLI 读取相关 turn;不得只依据标题、元数据或地图回答。`, + `可用 CLI 命令前缀: ${cli}`, + '按 info → outline/search → export 的顺序渐进读取;不要直接 Read 原始 .jsonl 历史文件。', + ].join('\n') } - return `不要假设这些会话的内容;需要上下文时,请先读取对应的 History path,再基于读取结果继续完成任务。\n\n重要提示:会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大。请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。避免一次性 Read 整个大文件。` + return `这些会话仅提供受限 turn 地图,不含完整正文。若回答依赖被引用会话的内容,必须先读取对应的 History path,再基于读取结果继续完成任务。会话历史文件(.jsonl)可能包含大量消息和 tool results,应优先用 Grep 定位后局部读取,避免一次性 Read 整个文件。` +} + +/** Pi 没有原生 Skill 工具;显式会话引用必须显式展开 session-cleaner 的 SOP。 */ +export function mergeSessionCleanerSkillMention( + mentionedSkills: string[] | undefined, + mentionedSessionIds: string[] | undefined, +): string[] | undefined { + if (!mentionedSessionIds?.some(Boolean)) return mentionedSkills + return [...new Set([...(mentionedSkills ?? []), 'session-cleaner'])] } /** @@ -172,14 +227,17 @@ export function buildRecoveryPrompt( return `${recoveryBlock}\n\n${currentUserMessage}` } -function escapeContextAttr(value: string): string { +function escapeContextText(value: string): string { return value .replace(/&/g, '&') - .replace(/"/g, '"') .replace(//g, '>') } +function escapeContextAttr(value: string): string { + return escapeContextText(value).replace(/"/g, '"') +} + export function buildReferencedSessionsPrompt( currentSessionId: string, mentionedSessionIds?: string[], @@ -201,10 +259,21 @@ export function buildReferencedSessionsPrompt( const title = escapeContextAttr(meta.title) const historyPath = getSessionHistoryPath(referencedSessionId) + const cli = getSessionCliCommandPrefix() + const commands = cli + ? [ + `读取此会话:${cli} session info ${referencedSessionId}`, + `${cli} session outline ${referencedSessionId}`, + `根据地图选择 ${cli} session export ${referencedSessionId} --turns A-B 或 --tail N。`, + ].join('\n') + : '' + const sessionOutline = buildReferencedSessionOutline(referencedSessionId) sessionBlocks.push( `\n` + `CLI target: ${referencedSessionId}\n` + `History path: ${historyPath}\n` + + `${commands}\n` + + `${sessionOutline ?? ''}\n` + '', ) } diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts index 527fbe6a7..cc673ddd9 100644 --- a/apps/electron/src/main/lib/config-paths.ts +++ b/apps/electron/src/main/lib/config-paths.ts @@ -412,6 +412,23 @@ export function getBundledCliPath(): string | undefined { return existsSync(cliPath) ? cliPath : undefined } +/** + * 获取开发模式下 CLI 的 TypeScript 入口。 + * + * 打包版使用自包含二进制;开发模式则由 Bun 直接运行源码,并显式加 --dev + * 以读取 ~/.proma-dev,避免误读正式版会话库。 + */ +export function getDevCliEntryPath(): string | undefined { + try { + const { app } = require('electron') + if (app.isPackaged || typeof app.getAppPath !== 'function') return undefined + const entryPath = join(app.getAppPath(), '..', 'cli', 'src', 'index.ts') + return existsSync(entryPath) ? entryPath : undefined + } catch { + return undefined + } +} + /** * 从 SKILL.md 的 YAML frontmatter 中解析 version 字段 *