diff --git a/src/agent-prompt-ingestion.test.ts b/src/agent-prompt-ingestion.test.ts new file mode 100644 index 0000000..8ddec81 --- /dev/null +++ b/src/agent-prompt-ingestion.test.ts @@ -0,0 +1,127 @@ +import fs from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + preparePromptIngestion, + recordPromptIngestion, +} from './agent-prompt-ingestion.js'; + +describe('agent prompt ingestion', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync('/tmp/ejclaw-prompt-ingestion-'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeClaudePrompt(text: string): string { + const claudeDir = path.join(tempDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const promptPath = path.join(claudeDir, 'CLAUDE.md'); + fs.writeFileSync(promptPath, text); + return claudeDir; + } + + function writeCodexPrompt(text: string): string { + const codexDir = path.join(tempDir, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const promptPath = path.join(codexDir, 'AGENTS.md'); + fs.writeFileSync(promptPath, text); + return codexDir; + } + + it('injects the role prompt when starting a fresh Claude session', () => { + const claudeDir = writeClaudePrompt('platform rules\n\npaired rules\n'); + + const plan = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: 'review current changes', + }); + + expect(plan.prompt).toContain(''); + expect(plan.prompt).toContain('platform rules'); + expect(plan.prompt).toContain('paired rules'); + expect(plan.prompt).toContain('Current task:\nreview current changes'); + }); + + it('does not reinject an unchanged prompt into an existing session', () => { + const codexDir = writeCodexPrompt('codex platform rules\n'); + const first = preparePromptIngestion({ + agentType: 'codex', + env: { CODEX_HOME: codexDir }, + prompt: 'first task', + }); + recordPromptIngestion(first); + + const second = preparePromptIngestion({ + agentType: 'codex', + env: { CODEX_HOME: codexDir }, + prompt: 'second task', + sessionId: 'session-existing', + }); + + expect(second.prompt).toBe('second task'); + }); + + it('reinjects when the prompt pack changes in an existing session', () => { + const claudeDir = writeClaudePrompt('old rules\n'); + const first = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: 'first task', + sessionId: 'session-existing', + }); + recordPromptIngestion(first); + + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'new rules\n'); + const second = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: 'second task', + sessionId: 'session-existing', + }); + + expect(second.prompt).toContain('new rules'); + expect(second.prompt).toContain('Current task:\nsecond task'); + }); + + it('does not treat a one-time memory briefing as a prompt pack change', () => { + const claudeDir = writeClaudePrompt( + 'stable rules\n\n---\n\n## Shared Room Memory\n- remembered', + ); + const first = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: 'first task', + memoryBriefing: '## Shared Room Memory\n- remembered', + }); + recordPromptIngestion(first); + + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'stable rules\n'); + const second = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: 'second task', + sessionId: 'session-existing', + }); + + expect(second.prompt).toBe('second task'); + }); + + it('does not inject role prompts into compact commands', () => { + const claudeDir = writeClaudePrompt('platform rules\n'); + + const plan = preparePromptIngestion({ + agentType: 'claude-code', + env: { CLAUDE_CONFIG_DIR: claudeDir }, + prompt: '/compact', + }); + + expect(plan.prompt).toBe('/compact'); + }); +}); diff --git a/src/agent-prompt-ingestion.ts b/src/agent-prompt-ingestion.ts new file mode 100644 index 0000000..7a4abde --- /dev/null +++ b/src/agent-prompt-ingestion.ts @@ -0,0 +1,132 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import type { AgentType } from './types.js'; + +const PROMPT_INGESTION_STATE_FILE = '.ejclaw-prompt-ingestion.json'; + +export interface PromptIngestionPlan { + prompt: string; + fingerprint?: string; + statePath?: string; +} + +function hashPrompt(prompt: string): string { + return crypto.createHash('sha256').update(prompt).digest('hex'); +} + +function readStoredFingerprint(statePath: string): string | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { + fingerprint?: unknown; + }; + return typeof parsed.fingerprint === 'string' + ? parsed.fingerprint + : undefined; + } catch { + return undefined; + } +} + +function stripTrailingMemoryBriefing( + promptContext: string, + memoryBriefing?: string, +): string { + if (!memoryBriefing) { + return promptContext; + } + const suffix = `\n\n---\n\n${memoryBriefing}`; + return promptContext.endsWith(suffix) + ? promptContext.slice(0, -suffix.length) + : promptContext; +} + +function resolvePromptContextPath(args: { + agentType: AgentType; + env: Record; +}): string | undefined { + if (args.agentType === 'codex' && args.env.CODEX_HOME) { + return path.join(args.env.CODEX_HOME, 'AGENTS.md'); + } + if (args.env.CLAUDE_CONFIG_DIR) { + return path.join(args.env.CLAUDE_CONFIG_DIR, 'CLAUDE.md'); + } + if (args.env.CODEX_HOME) { + return path.join(args.env.CODEX_HOME, 'AGENTS.md'); + } + return undefined; +} + +function buildPromptBootstrap(promptContext: string, prompt: string): string { + return `System bootstrap: +Load and follow the current EJClaw role prompt below. This bootstrap is injected only when a role session starts or when the prompt pack changes. Do not summarize it; apply it silently to the task that follows. + + +${promptContext} + + +Current task: +${prompt}`; +} + +export function preparePromptIngestion(args: { + agentType: AgentType; + env: Record; + prompt: string; + sessionId?: string; + memoryBriefing?: string; +}): PromptIngestionPlan { + if (args.prompt.trim() === '/compact') { + return { prompt: args.prompt }; + } + + const promptContextPath = resolvePromptContextPath(args); + if (!promptContextPath || !fs.existsSync(promptContextPath)) { + return { prompt: args.prompt }; + } + + const promptContext = fs.readFileSync(promptContextPath, 'utf-8').trim(); + if (!promptContext) { + return { prompt: args.prompt }; + } + + const fingerprintSource = stripTrailingMemoryBriefing( + promptContext, + args.memoryBriefing, + ).trim(); + const fingerprint = hashPrompt(fingerprintSource || promptContext); + const statePath = path.join( + path.dirname(promptContextPath), + PROMPT_INGESTION_STATE_FILE, + ); + const storedFingerprint = readStoredFingerprint(statePath); + + if (args.sessionId && storedFingerprint === fingerprint) { + return { prompt: args.prompt, fingerprint, statePath }; + } + + return { + prompt: buildPromptBootstrap(promptContext, args.prompt), + fingerprint, + statePath, + }; +} + +export function recordPromptIngestion(plan: PromptIngestionPlan): void { + if (!plan.fingerprint || !plan.statePath) { + return; + } + fs.mkdirSync(path.dirname(plan.statePath), { recursive: true }); + fs.writeFileSync( + plan.statePath, + JSON.stringify( + { + fingerprint: plan.fingerprint, + ingested_at: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + ); +} diff --git a/src/agent-runner.ts b/src/agent-runner.ts index f1b9a84..108aa81 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -12,6 +12,10 @@ import { prepareReadonlySessionEnvironment, prepareGroupEnvironment, } from './agent-runner-environment.js'; +import { + preparePromptIngestion, + recordPromptIngestion, +} from './agent-prompt-ingestion.js'; import { runSpawnedAgentProcess } from './agent-runner-process.js'; import { getStoredRoomSkillOverrides } from './db.js'; export { @@ -164,6 +168,19 @@ export async function runAgentProcess( 'Spawning agent process', ); + const runnerInput: AgentInput = { + ...input, + ...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}), + }; + const promptIngestion = preparePromptIngestion({ + agentType: group.agentType || 'claude-code', + env, + prompt: runnerInput.prompt, + sessionId: runnerInput.sessionId, + memoryBriefing: runnerInput.memoryBriefing, + }); + runnerInput.prompt = promptIngestion.prompt; + const logsDir = path.join(groupDir, 'logs'); fs.mkdirSync(logsDir, { recursive: true }); @@ -176,10 +193,6 @@ export async function runAgentProcess( onProcess(proc, processName, env.EJCLAW_IPC_DIR); - const runnerInput: AgentInput = { - ...input, - ...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}), - }; proc.stdin.write(JSON.stringify(runnerInput)); proc.stdin.end(); @@ -191,6 +204,11 @@ export async function runAgentProcess( logsDir, startTime, onOutput, - }).then(resolve); + }).then((result) => { + if (result.status === 'success') { + recordPromptIngestion(promptIngestion); + } + resolve(result); + }); }); }