diff --git a/AGENTS.md b/AGENTS.md index d06e2fe2..9e5fc050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Agent Note supports multiple coding agents via an adapter pattern: - **SessionStart**: Create session directory, write heartbeat, store agent name via `writeSessionAgent()` - **Stop**: Log stop event only — does **not** invalidate heartbeat (Stop = AI response end, not session end) -- **UserPromptSubmit**: Append prompt to `prompts.jsonl`, increment turn counter in `turn` file. System-injected messages (``, ``, ``) are filtered by the Claude adapter to prevent turn pollution +- **UserPromptSubmit**: Normalize prompt text, append it to `prompts.jsonl`, increment turn counter in `turn` file. Agent Note strips leading runtime metadata such as `` while preserving the actual user request, and drops standalone system-injected messages (``, ``, ``) to prevent turn pollution - **PreToolUse (Edit/Write/MultiEdit/NotebookEdit)**: Capture pre-edit blob hash via `git hash-object -w` for line-level attribution (synchronous) - **PreToolUse (Bash, git commit)**: Inject `--trailer` into the `git commit` segment only via regex replace (`cmd.replace(/(git\s+commit)/, ...)`) to handle chained commands like `git commit && git push` (synchronous, must write to stdout) - **PostToolUse (Edit/Write/MultiEdit/NotebookEdit)**: Track file changes in `changes.jsonl` with current turn number and post-edit blob hash diff --git a/CLAUDE.md b/CLAUDE.md index c95d6a07..b1e32d16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ Agent Note supports multiple coding agents via an adapter pattern: - **SessionStart**: Create session directory, write heartbeat, store agent name via `writeSessionAgent()` - **Stop**: Log stop event only — does **not** invalidate heartbeat (Stop = AI response end, not session end) -- **UserPromptSubmit**: Append prompt to `prompts.jsonl`, increment turn counter in `turn` file. System-injected messages (``, ``, ``) are filtered by the Claude adapter to prevent turn pollution +- **UserPromptSubmit**: Normalize prompt text, append it to `prompts.jsonl`, increment turn counter in `turn` file. Agent Note strips leading runtime metadata such as `` while preserving the actual user request, and drops standalone system-injected messages (``, ``, ``) to prevent turn pollution - **PreToolUse (Edit/Write/MultiEdit/NotebookEdit)**: Capture pre-edit blob hash via `git hash-object -w` for line-level attribution (synchronous) - **PreToolUse (Bash, git commit)**: Inject `--trailer` into the `git commit` segment only via regex replace (`cmd.replace(/(git\s+commit)/, ...)`) to handle chained commands like `git commit && git push` (synchronous, must write to stdout) - **PostToolUse (Edit/Write/MultiEdit/NotebookEdit)**: Track file changes in `changes.jsonl` with current turn number and post-edit blob hash diff --git a/docs/architecture.md b/docs/architecture.md index 3e5c8067..5e8bf07d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,6 +162,8 @@ session .jsonl .jsonl AI agent hooks handle data **collection** (prompts, file changes, session lifecycle, transcript references). Git hooks handle commit **integration** (trailer injection, note recording). For Claude Code, Codex, Cursor, and Gemini CLI, this means plain `git commit` works when the repository-local git hooks are installed. Cursor preview also recovers prompt / response pairs from Cursor response hooks or local transcripts, and its shell hooks provide a fallback path when git hooks are unavailable. +Before prompt text becomes durable note data, adapters normalize it with a shared sanitizer. Leading runtime metadata blocks such as `` are stripped while the actual user request is preserved, and standalone system-injected messages such as ``, ``, and `` are dropped. The same sanitizer is applied to hook prompt events and transcript recovery so PR Report, Dashboard, `show`, and `why` see consistent prompt text. + ### Storage: two layers **Layer 1 — Local temp (`.git/agentnote/sessions/`)** diff --git a/docs/knowledge/prompt-context.md b/docs/knowledge/prompt-context.md index 6380ec9a..951bb5a6 100644 --- a/docs/knowledge/prompt-context.md +++ b/docs/knowledge/prompt-context.md @@ -317,13 +317,13 @@ sentence boundary: `contexts[]` がある場合は、prompt の前に 1 つの `📝 Context` block として表示します。 ```md -> **📝 Context** +**📝 Context** > ... > -> **🧑 Prompt** +**🧑 Prompt** > ... > -> **🤖 Response** +**🤖 Response** > ... ``` diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 162c1c03..d459ec2a 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -358,6 +358,29 @@ function isAgentNoteHookCommand(command2, agentName, options = {}) { return options.allowMissingAgent === true && agentFlag === null; } +// src/agents/prompt-text.ts +var SYSTEM_PROMPT_TAG_RE = /^\s*<(task-notification|system-reminder|teammate-message)(?:\s[^>]*)?\s*(?:\/>|>[\s\S]*?<\/\1\s*>)\s*$/i; +var LEADING_ENVIRONMENT_CONTEXT_RE = /^\s*]*)?>[\s\S]*?<\/environment_context>\s*/i; +var LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE = /^\s*]*)?\/>\s*/i; +function isSystemInjectedPrompt(prompt) { + return SYSTEM_PROMPT_TAG_RE.test(prompt); +} +function stripLeadingEnvironmentContext(prompt) { + let next = prompt; + while (true) { + const stripped = next.replace(LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE, "").replace(LEADING_ENVIRONMENT_CONTEXT_RE, ""); + if (stripped === next) return next; + next = stripped; + } +} +function normalizeUserPromptText(prompt) { + const trimmed = prompt?.trim(); + if (!trimmed) return null; + const withoutRuntimeMetadata = stripLeadingEnvironmentContext(trimmed).trim(); + if (!withoutRuntimeMetadata || isSystemInjectedPrompt(withoutRuntimeMetadata)) return null; + return withoutRuntimeMetadata; +} + // src/agents/types.ts var AGENT_NAMES = { claude: "claude", @@ -442,18 +465,6 @@ function isValidTranscriptPath(p) { const normalized = resolve(p); return normalized === base || normalized.startsWith(`${base}${sep}`); } -var SYSTEM_PROMPT_PREFIXES = ["" || next === " " || next === "\n" || next === void 0) { - return true; - } - } - } - return false; -} function isGitCommit(cmd) { return findGitCommitCommand(cmd) !== null; } @@ -566,16 +577,18 @@ var claude = { timestamp: ts, transcriptPath: tp }; - case CLAUDE_HOOK_EVENTS.userPromptSubmit: - if (!e.prompt || isSystemInjectedPrompt(e.prompt)) { + case CLAUDE_HOOK_EVENTS.userPromptSubmit: { + const prompt = normalizeUserPromptText(e.prompt); + if (!prompt) { return null; } return { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId: sid, timestamp: ts, - prompt: e.prompt + prompt }; + } case CLAUDE_HOOK_EVENTS.preToolUse: { const tool = e.tool_name; const cmd = e.tool_input?.command ?? ""; @@ -653,8 +666,15 @@ var claude = { let pendingResponseTexts = []; const flush = () => { if (pendingPrompt === null) return; + const prompt = normalizeUserPromptText(pendingPrompt); + if (!prompt) { + pendingPrompt = null; + pendingPromptTimestamp = void 0; + pendingResponseTexts = []; + return; + } const response = pendingResponseTexts.length > 0 ? pendingResponseTexts.join("\n") : null; - const interaction = { prompt: pendingPrompt, response }; + const interaction = { prompt, response }; if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; interactions.push(interaction); pendingPrompt = null; @@ -1072,15 +1092,17 @@ var codex = { model: payload.model, transcriptPath }; - case CODEX_HOOK_EVENTS.userPromptSubmit: - return payload.prompt ? { + case CODEX_HOOK_EVENTS.userPromptSubmit: { + const prompt = normalizeUserPromptText(payload.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId, timestamp, - prompt: payload.prompt, + prompt, transcriptPath, model: payload.model } : null; + } case CODEX_HOOK_EVENTS.stop: return { kind: NORMALIZED_EVENT_KINDS.stop, @@ -1135,7 +1157,7 @@ var codex = { const payloadType = typeof payload.type === "string" ? payload.type : void 0; const payloadRole = typeof payload.role === "string" ? payload.role : void 0; if (payloadType === "message" && payloadRole === "user") { - const prompt = collectMessageText(payload.content).join("\n"); + const prompt = normalizeUserPromptText(collectMessageText(payload.content).join("\n")); if (!prompt) continue; if (current) interactions.push(current); current = { prompt, response: null }; @@ -1341,9 +1363,10 @@ function extractPlainTextInteractions(content) { let currentResponse = []; let activeRole = null; const flush = () => { - if (!currentPrompt?.trim()) return; + const prompt = normalizeUserPromptText(currentPrompt); + if (!prompt) return; interactions.push({ - prompt: currentPrompt.trim(), + prompt, response: currentResponse.length > 0 ? currentResponse.join("\n").trim() : null }); }; @@ -1392,9 +1415,10 @@ function extractJsonlInteractions(content) { let pendingPromptTimestamp; let pendingResponse = []; const flush = () => { - if (!pendingPrompt?.trim()) return; + const prompt = normalizeUserPromptText(pendingPrompt); + if (!prompt) return; const interaction = { - prompt: pendingPrompt.trim(), + prompt, response: pendingResponse.length > 0 ? pendingResponse.join("\n").trim() : null }; if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; @@ -1530,14 +1554,16 @@ var cursor = { if (!sessionId) return null; const timestamp = (/* @__PURE__ */ new Date()).toISOString(); switch (payload.hook_event_name) { - case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: - return payload.prompt ? { + case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: { + const prompt = normalizeUserPromptText(payload.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId, timestamp, - prompt: payload.prompt, + prompt, model: payload.model } : null; + } case CURSOR_HOOK_EVENTS.afterAgentResponse: { const response = collectMessageText2( payload.response ?? payload.text ?? payload.content ?? payload.message ?? payload.output @@ -1906,14 +1932,16 @@ var gemini = { timestamp: ts, transcriptPath: tp }; - case GEMINI_HOOK_EVENTS.beforeAgent: - return e.prompt ? { + case GEMINI_HOOK_EVENTS.beforeAgent: { + const prompt = normalizeUserPromptText(e.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId: sid, timestamp: ts, - prompt: e.prompt, + prompt, model: e.model } : null; + } case GEMINI_HOOK_EVENTS.afterAgent: return e.prompt_response ? { kind: NORMALIZED_EVENT_KINDS.response, @@ -1999,7 +2027,7 @@ var gemini = { const type = typeof record2.type === "string" ? record2.type : void 0; if (!type) continue; if (type === "user") { - const prompt = extractPartText(record2.content); + const prompt = normalizeUserPromptText(extractPartText(record2.content)); if (!prompt) continue; if (current) interactions.push(current); current = { prompt, response: null }; @@ -6762,7 +6790,7 @@ function cleanPrompt(prompt, maxLen) { return `${body.slice(0, maxLen)}\u2026`; } function pushBlockquoteSection(lines, label, body) { - lines.push(`> **${label}**`); + lines.push(`**${label}**`); lines.push(`> ${body.split("\n").join("\n> ")}`); } function renderInteractionContext(interaction) { diff --git a/packages/cli/src/agents/claude.test.ts b/packages/cli/src/agents/claude.test.ts index a6bbfb78..66eec1db 100644 --- a/packages/cli/src/agents/claude.test.ts +++ b/packages/cli/src/agents/claude.test.ts @@ -81,6 +81,21 @@ describe("claude adapter", () => { assert.equal(event.prompt, "Refactor the auth module"); }); + it("strips leading environment metadata from UserPromptSubmit", () => { + const event = claude.parseEvent({ + raw: JSON.stringify({ + hook_event_name: "UserPromptSubmit", + session_id: VALID_SESSION_ID, + prompt: + "\n2026-05-15\n\n\nRefactor the auth module", + }), + sync: false, + }); + assert.ok(event !== null); + assert.equal(event.kind, "prompt"); + assert.equal(event.prompt, "Refactor the auth module"); + }); + it("returns null for UserPromptSubmit without prompt", () => { const event = claude.parseEvent({ raw: JSON.stringify({ @@ -759,6 +774,32 @@ describe("claude adapter", () => { assert.equal(interactions[1].response, "Done."); }); + it("strips leading environment metadata from transcript prompts", async () => { + const transcriptPath = join(claudeHome, "session.jsonl"); + const lines = [ + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "text", + text: "\nAsia/Tokyo\n\n\nUpdate the PR report", + }, + ], + }, + }), + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "I will update it." }] }, + }), + ]; + writeFileSync(transcriptPath, lines.join("\n")); + + const interactions = await claude.extractInteractions(transcriptPath); + assert.equal(interactions.length, 1); + assert.equal(interactions[0].prompt, "Update the PR report"); + }); + it("handles user prompt without response", async () => { const transcriptPath = join(claudeHome, "session.jsonl"); const lines = [ diff --git a/packages/cli/src/agents/claude.ts b/packages/cli/src/agents/claude.ts index f865a232..31686cea 100644 --- a/packages/cli/src/agents/claude.ts +++ b/packages/cli/src/agents/claude.ts @@ -5,6 +5,7 @@ import { join, resolve, sep } from "node:path"; import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; import { isAgentNoteHookCommand } from "./hook-command.js"; +import { normalizeUserPromptText } from "./prompt-text.js"; import { AGENT_NAMES, type AgentAdapter, @@ -100,22 +101,6 @@ interface ClaudeEvent { transcript_path?: string; } -/** Known system-injected message prefixes. Match `` or `' or ' ' (attributes) to be a real tag. - if (next === ">" || next === " " || next === "\n" || next === undefined) { - return true; - } - } - } - return false; -} - function isGitCommit(cmd: string): boolean { // Support chained commands like "git add ... && git commit ..." return findGitCommitCommand(cmd) !== null; @@ -255,20 +240,22 @@ export const claude: AgentAdapter = { timestamp: ts, transcriptPath: tp, }; - case CLAUDE_HOOK_EVENTS.userPromptSubmit: + case CLAUDE_HOOK_EVENTS.userPromptSubmit: { // Claude Code fires UserPromptSubmit for system-injected messages // (task notifications, reminders, teammate messages) that are not // real user prompts. Skip them to keep turn attribution correct. // Heartbeat is still refreshed by hook.ts's null-event path. - if (!e.prompt || isSystemInjectedPrompt(e.prompt)) { + const prompt = normalizeUserPromptText(e.prompt); + if (!prompt) { return null; } return { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId: sid, timestamp: ts, - prompt: e.prompt, + prompt, }; + } case CLAUDE_HOOK_EVENTS.preToolUse: { const tool = e.tool_name; const cmd = e.tool_input?.command ?? ""; @@ -353,8 +340,15 @@ export const claude: AgentAdapter = { const flush = () => { if (pendingPrompt === null) return; + const prompt = normalizeUserPromptText(pendingPrompt); + if (!prompt) { + pendingPrompt = null; + pendingPromptTimestamp = undefined; + pendingResponseTexts = []; + return; + } const response = pendingResponseTexts.length > 0 ? pendingResponseTexts.join("\n") : null; - const interaction: TranscriptInteraction = { prompt: pendingPrompt, response }; + const interaction: TranscriptInteraction = { prompt, response }; if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; interactions.push(interaction); pendingPrompt = null; diff --git a/packages/cli/src/agents/codex.test.ts b/packages/cli/src/agents/codex.test.ts index cb2eb7be..8779b90b 100644 --- a/packages/cli/src/agents/codex.test.ts +++ b/packages/cli/src/agents/codex.test.ts @@ -115,6 +115,22 @@ describe("codex adapter", () => { assert.ok(event !== null); assert.equal(event.transcriptPath, undefined, "escaped path must be rejected"); }); + + it("strips leading environment metadata from UserPromptSubmit", () => { + const event = codex.parseEvent({ + raw: JSON.stringify({ + hook_event_name: "UserPromptSubmit", + session_id: VALID_SESSION_ID, + prompt: + "\nAsia/Tokyo\n\n\nUpdate the PR report", + }), + sync: false, + }); + + assert.ok(event !== null); + assert.equal(event.kind, "prompt"); + assert.equal(event.prompt, "Update the PR report"); + }); }); it("extracts interactions from nested message content and function_call apply_patch payloads", async () => { @@ -137,6 +153,22 @@ describe("codex adapter", () => { assert.deepEqual(interactions[0].line_stats, { "src/greet.ts": { added: 1, deleted: 1 } }); }); + it("strips leading environment metadata from transcript prompts", async () => { + const transcriptDir = join(codexHome, "sessions", "environment"); + mkdirSync(transcriptDir, { recursive: true }); + const transcriptPath = join(transcriptDir, "rollout.jsonl"); + + writeFileSync( + transcriptPath, + '{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\\nAsia/Tokyo\\n\\n\\nUpdate the PR report"}]}}\n' + + '{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I will update it."}]}}\n', + ); + + const interactions = await codex.extractInteractions(transcriptPath); + assert.equal(interactions.length, 1); + assert.equal(interactions[0].prompt, "Update the PR report"); + }); + it("extracts tools and patch metadata from a real-session-derived fixture", async () => { const transcriptDir = join(codexHome, "sessions", "fixture"); mkdirSync(transcriptDir, { recursive: true }); diff --git a/packages/cli/src/agents/codex.ts b/packages/cli/src/agents/codex.ts index af502d77..7e950600 100644 --- a/packages/cli/src/agents/codex.ts +++ b/packages/cli/src/agents/codex.ts @@ -5,6 +5,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { createInterface } from "node:readline"; import { TEXT_ENCODING } from "../core/constants.js"; import { isAgentNoteHookCommand } from "./hook-command.js"; +import { normalizeUserPromptText } from "./prompt-text.js"; import { AGENT_NAMES, type AgentAdapter, @@ -497,17 +498,19 @@ export const codex: AgentAdapter = { model: payload.model, transcriptPath, }; - case CODEX_HOOK_EVENTS.userPromptSubmit: - return payload.prompt + case CODEX_HOOK_EVENTS.userPromptSubmit: { + const prompt = normalizeUserPromptText(payload.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId, timestamp, - prompt: payload.prompt, + prompt, transcriptPath, model: payload.model, } : null; + } case CODEX_HOOK_EVENTS.stop: return { kind: NORMALIZED_EVENT_KINDS.stop, @@ -573,7 +576,7 @@ export const codex: AgentAdapter = { const payloadRole = typeof payload.role === "string" ? payload.role : undefined; if (payloadType === "message" && payloadRole === "user") { - const prompt = collectMessageText(payload.content).join("\n"); + const prompt = normalizeUserPromptText(collectMessageText(payload.content).join("\n")); if (!prompt) continue; if (current) interactions.push(current); current = { prompt, response: null }; diff --git a/packages/cli/src/agents/cursor.test.ts b/packages/cli/src/agents/cursor.test.ts index 75764618..76bf221b 100644 --- a/packages/cli/src/agents/cursor.test.ts +++ b/packages/cli/src/agents/cursor.test.ts @@ -221,6 +221,22 @@ describe("cursor adapter", () => { }); }); + it("strips leading environment metadata from prompt events", () => { + const event = cursor.parseEvent({ + raw: JSON.stringify({ + hook_event_name: "beforeSubmitPrompt", + conversation_id: "conv-123", + prompt: + "\nAsia/Tokyo\n\n\nRefactor src/main.ts", + }), + sync: true, + }); + + assert.ok(event !== null); + assert.equal(event.kind, "prompt"); + assert.equal(event.prompt, "Refactor src/main.ts"); + }); + it("returns null when shell hooks only mention git commit in a quoted string or comment", () => { for (const command of ['echo "git commit -m test"', "git status # git commit -m test"]) { const before = cursor.parseEvent({ @@ -279,4 +295,18 @@ describe("cursor adapter", () => { { prompt: "Add tests too", response: "Adding tests now." }, ]); }); + + it("strips leading environment metadata from Cursor transcript prompts", async () => { + const transcriptPath = join(transcriptDir, "cursor-environment.jsonl"); + writeFileSync( + transcriptPath, + '{"role":"user","parts":[{"type":"text","text":"\\nAsia/Tokyo\\n\\n\\nUpdate the PR report"}]}\n' + + '{"role":"assistant","parts":[{"type":"text","text":"I will update it."}]}\n', + ); + + const interactions = await cursor.extractInteractions(transcriptPath); + assert.deepEqual(interactions, [ + { prompt: "Update the PR report", response: "I will update it." }, + ]); + }); }); diff --git a/packages/cli/src/agents/cursor.ts b/packages/cli/src/agents/cursor.ts index 5f7f6b5c..517d9603 100644 --- a/packages/cli/src/agents/cursor.ts +++ b/packages/cli/src/agents/cursor.ts @@ -6,6 +6,7 @@ import { join, resolve, sep } from "node:path"; import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; import { isAgentNoteHookCommand } from "./hook-command.js"; +import { normalizeUserPromptText } from "./prompt-text.js"; import { AGENT_NAMES, type AgentAdapter, @@ -224,9 +225,10 @@ function extractPlainTextInteractions(content: string): TranscriptInteraction[] let activeRole: "user" | "assistant" | null = null; const flush = () => { - if (!currentPrompt?.trim()) return; + const prompt = normalizeUserPromptText(currentPrompt); + if (!prompt) return; interactions.push({ - prompt: currentPrompt.trim(), + prompt, response: currentResponse.length > 0 ? currentResponse.join("\n").trim() : null, }); }; @@ -285,9 +287,10 @@ function extractJsonlInteractions(content: string): TranscriptInteraction[] { let pendingResponse: string[] = []; const flush = () => { - if (!pendingPrompt?.trim()) return; + const prompt = normalizeUserPromptText(pendingPrompt); + if (!prompt) return; const interaction: TranscriptInteraction = { - prompt: pendingPrompt.trim(), + prompt, response: pendingResponse.length > 0 ? pendingResponse.join("\n").trim() : null, }; if (pendingPromptTimestamp) interaction.timestamp = pendingPromptTimestamp; @@ -460,16 +463,18 @@ export const cursor: AgentAdapter = { const timestamp = new Date().toISOString(); switch (payload.hook_event_name) { - case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: - return payload.prompt + case CURSOR_HOOK_EVENTS.beforeSubmitPrompt: { + const prompt = normalizeUserPromptText(payload.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId, timestamp, - prompt: payload.prompt, + prompt, model: payload.model, } : null; + } case CURSOR_HOOK_EVENTS.afterAgentResponse: { const response = collectMessageText( diff --git a/packages/cli/src/agents/gemini.test.ts b/packages/cli/src/agents/gemini.test.ts index 47848aa9..ccd33790 100644 --- a/packages/cli/src/agents/gemini.test.ts +++ b/packages/cli/src/agents/gemini.test.ts @@ -83,6 +83,21 @@ describe("gemini adapter", () => { assert.equal(event.model, "gemini-2.0-flash"); }); + it("strips leading environment metadata from BeforeAgent prompts", () => { + const event = gemini.parseEvent({ + raw: JSON.stringify({ + hook_event_name: "BeforeAgent", + session_id: VALID_SESSION_ID, + prompt: + "\nAsia/Tokyo\n\n\nRefactor the auth module", + }), + sync: false, + }); + assert.ok(event !== null); + assert.equal(event.kind, "prompt"); + assert.equal(event.prompt, "Refactor the auth module"); + }); + it("returns null for BeforeAgent without prompt", () => { const event = gemini.parseEvent({ raw: JSON.stringify({ @@ -705,6 +720,30 @@ describe("gemini adapter", () => { assert.equal(interactions[1].response, "Done."); }); + it("strips leading environment metadata from transcript prompts", async () => { + const transcriptPath = join(geminiHome, "session.jsonl"); + const lines = [ + JSON.stringify({ sessionId: VALID_SESSION_ID }), + JSON.stringify({ + type: "user", + content: [ + { + text: "\nAsia/Tokyo\n\n\nUpdate the PR report", + }, + ], + }), + JSON.stringify({ + type: "gemini", + content: [{ text: "I will update it." }], + }), + ]; + writeFileSync(transcriptPath, lines.join("\n")); + + const interactions = await gemini.extractInteractions(transcriptPath); + assert.equal(interactions.length, 1); + assert.equal(interactions[0].prompt, "Update the PR report"); + }); + it("extracts files_touched from replace tool calls", async () => { const transcriptPath = join(geminiHome, "session.jsonl"); const lines = [ diff --git a/packages/cli/src/agents/gemini.ts b/packages/cli/src/agents/gemini.ts index 1c0dea3a..82122319 100644 --- a/packages/cli/src/agents/gemini.ts +++ b/packages/cli/src/agents/gemini.ts @@ -5,6 +5,7 @@ import { dirname, join, resolve, sep } from "node:path"; import { TEXT_ENCODING } from "../core/constants.js"; import { findGitCommitCommand } from "../git.js"; import { isAgentNoteHookCommand } from "./hook-command.js"; +import { normalizeUserPromptText } from "./prompt-text.js"; import { AGENT_NAMES, type AgentAdapter, @@ -404,16 +405,18 @@ export const gemini: AgentAdapter = { transcriptPath: tp, }; - case GEMINI_HOOK_EVENTS.beforeAgent: - return e.prompt + case GEMINI_HOOK_EVENTS.beforeAgent: { + const prompt = normalizeUserPromptText(e.prompt); + return prompt ? { kind: NORMALIZED_EVENT_KINDS.prompt, sessionId: sid, timestamp: ts, - prompt: e.prompt, + prompt, model: e.model, } : null; + } case GEMINI_HOOK_EVENTS.afterAgent: return e.prompt_response @@ -518,7 +521,7 @@ export const gemini: AgentAdapter = { if (!type) continue; // skip metadata lines (ConversationRecord, $rewindTo, $set) if (type === "user") { - const prompt = extractPartText(record.content); + const prompt = normalizeUserPromptText(extractPartText(record.content)); if (!prompt) continue; if (current) interactions.push(current); current = { prompt, response: null }; diff --git a/packages/cli/src/agents/prompt-text.test.ts b/packages/cli/src/agents/prompt-text.test.ts new file mode 100644 index 00000000..97bd8d23 --- /dev/null +++ b/packages/cli/src/agents/prompt-text.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { normalizeUserPromptText } from "./prompt-text.js"; + +describe("normalizeUserPromptText", () => { + it("strips leading environment metadata and keeps the user prompt", () => { + const prompt = normalizeUserPromptText( + [ + "", + " 2026-05-15", + " Asia/Tokyo", + "", + "", + "Fix the PR report output.", + ].join("\n"), + ); + + assert.equal(prompt, "Fix the PR report output."); + }); + + it("drops metadata-only environment context prompts", () => { + const prompt = normalizeUserPromptText( + "\nAsia/Tokyo\n", + ); + + assert.equal(prompt, null); + }); + + it("strips leading self-closing environment metadata and keeps the user prompt", () => { + const prompt = normalizeUserPromptText( + '\nFix the PR report output.', + ); + + assert.equal(prompt, "Fix the PR report output."); + }); + + it("drops system-injected prompts after leading environment metadata", () => { + const prompt = normalizeUserPromptText( + [ + "", + "Asia/Tokyo", + "", + "Keep going.", + ].join("\n"), + ); + + assert.equal(prompt, null); + }); + + it("drops self-closing and mixed-case system-injected prompts", () => { + assert.equal(normalizeUserPromptText(""), null); + assert.equal(normalizeUserPromptText(''), null); + }); + + it("does not drop user text that follows a system-looking tag", () => { + const prompt = normalizeUserPromptText( + "Internal context.\nPlease fix the report.", + ); + + assert.equal( + prompt, + "Internal context.\nPlease fix the report.", + ); + }); + + it("does not strip environment_context text in the middle of a real prompt", () => { + const prompt = normalizeUserPromptText( + "Explain why appears in the PR report.", + ); + + assert.equal(prompt, "Explain why appears in the PR report."); + }); + + it("does not drop prompts that only mention system tag names", () => { + const prompt = normalizeUserPromptText("Please inspect the parser."); + + assert.equal(prompt, "Please inspect the parser."); + }); +}); diff --git a/packages/cli/src/agents/prompt-text.ts b/packages/cli/src/agents/prompt-text.ts new file mode 100644 index 00000000..a64564a9 --- /dev/null +++ b/packages/cli/src/agents/prompt-text.ts @@ -0,0 +1,33 @@ +const SYSTEM_PROMPT_TAG_RE = + /^\s*<(task-notification|system-reminder|teammate-message)(?:\s[^>]*)?\s*(?:\/>|>[\s\S]*?<\/\1\s*>)\s*$/i; +const LEADING_ENVIRONMENT_CONTEXT_RE = + /^\s*]*)?>[\s\S]*?<\/environment_context>\s*/i; +const LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE = /^\s*]*)?\/>\s*/i; + +/** True when the prompt is a standalone system-injected message, not user intent. */ +function isSystemInjectedPrompt(prompt: string): boolean { + return SYSTEM_PROMPT_TAG_RE.test(prompt); +} + +/** Strip leading runtime metadata blocks while preserving the user's actual prompt. */ +function stripLeadingEnvironmentContext(prompt: string): string { + let next = prompt; + while (true) { + const stripped = next + .replace(LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE, "") + .replace(LEADING_ENVIRONMENT_CONTEXT_RE, ""); + if (stripped === next) return next; + next = stripped; + } +} + +/** Normalize agent-supplied user text before it becomes durable Agent Note data. */ +export function normalizeUserPromptText(prompt: string | null | undefined): string | null { + const trimmed = prompt?.trim(); + if (!trimmed) return null; + + const withoutRuntimeMetadata = stripLeadingEnvironmentContext(trimmed).trim(); + if (!withoutRuntimeMetadata || isSystemInjectedPrompt(withoutRuntimeMetadata)) return null; + + return withoutRuntimeMetadata; +} diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index daa9e93d..dc76e645 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -28958,7 +28958,7 @@ __webpack_unused_export__ = defaultContentType /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; -/******/ +/******/ /******/ // The require function /******/ function __nccwpck_require__(moduleId) { /******/ // Check if module is in cache @@ -28972,7 +28972,7 @@ __webpack_unused_export__ = defaultContentType /******/ // no module.loaded needed /******/ exports: {} /******/ }; -/******/ +/******/ /******/ // Execute the module function /******/ var threw = true; /******/ try { @@ -28981,16 +28981,16 @@ __webpack_unused_export__ = defaultContentType /******/ } finally { /******/ if(threw) delete __webpack_module_cache__[moduleId]; /******/ } -/******/ +/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } -/******/ +/******/ /************************************************************************/ /******/ /* webpack/runtime/compat */ -/******/ +/******/ /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = new URL('.', import.meta.url).pathname.slice(import.meta.url.match(/^file:\/\/\/\w:/) ? 1 : 0, -1) + "/"; -/******/ +/******/ /************************************************************************/ var __webpack_exports__ = {}; @@ -30079,8 +30079,8 @@ class oidc_utils_OidcClient { const res = yield httpclient .getJson(id_token_url) .catch(error => { - throw new Error(`Failed to get ID Token. \n - Error Code : ${error.statusCode}\n + throw new Error(`Failed to get ID Token. \n + Error Code : ${error.statusCode}\n Error Message: ${error.message}`); }); const id_token = (_a = res.result) === null || _a === void 0 ? void 0 : _a.value; @@ -37906,9 +37906,9 @@ function cleanPrompt(prompt, maxLen) { return body; return `${body.slice(0, maxLen)}…`; } -/** Append a labeled blockquote section while preserving line breaks. */ +/** Append a visible section label followed by a blockquoted body. */ function pushBlockquoteSection(lines, label, body) { - lines.push(`> **${label}**`); + lines.push(`**${label}**`); lines.push(`> ${body.split("\n").join("\n> ")}`); } /** Render all structured interaction contexts in their stable display order. */ @@ -38122,3 +38122,4 @@ async function run() { } } run(); + diff --git a/packages/pr-report/src/report.test.ts b/packages/pr-report/src/report.test.ts index 0dffa1cf..09e5d920 100644 --- a/packages/pr-report/src/report.test.ts +++ b/packages/pr-report/src/report.test.ts @@ -416,6 +416,9 @@ describe("renderMarkdown", () => { assert.ok(promptIndex > contextIndex, "prompt should appear after context"); assert.ok(responseIndex > promptIndex, "response should appear after prompt"); assert.ok(markdown.includes("The previous response explains why src/record.ts needs this fix.")); + assert.ok(!markdown.includes("> **📝 Context**"), "context label should not be quoted"); + assert.ok(!markdown.includes("> **🧑 Prompt**"), "prompt label should not be quoted"); + assert.ok(!markdown.includes("> **🤖 Response**"), "response label should not be quoted"); }); it("renders contexts array in a single context block", () => { diff --git a/packages/pr-report/src/report.ts b/packages/pr-report/src/report.ts index 66f036bc..806094e9 100644 --- a/packages/pr-report/src/report.ts +++ b/packages/pr-report/src/report.ts @@ -869,9 +869,9 @@ function cleanPrompt(prompt: string, maxLen: number): string { return `${body.slice(0, maxLen)}…`; } -/** Append a labeled blockquote section while preserving line breaks. */ +/** Append a visible section label followed by a blockquoted body. */ function pushBlockquoteSection(lines: string[], label: string, body: string): void { - lines.push(`> **${label}**`); + lines.push(`**${label}**`); lines.push(`> ${body.split("\n").join("\n> ")}`); }