From b3755d2a11d5b36dd5da247e5f3a19c120b3dae4 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Fri, 15 May 2026 09:59:31 +0900 Subject: [PATCH 1/5] fix(report): render prompt labels outside blockquotes Move PR Report interaction labels out of the blockquote body so the label remains structural while prompt and response text stay quoted. Verification: npm run test --workspace packages/pr-report; npm run build --workspace packages/pr-report; npm run build --workspace packages/cli; npm run typecheck --workspace packages/cli; npm run lint --workspace packages/cli; node --import tsx/esm --test src/commands/pr.test.ts (from packages/cli). Release note: Render PR Report prompt and response labels outside quote blocks for clearer reading. --- docs/knowledge/prompt-context.md | 6 +++--- packages/cli/dist/cli.js | 2 +- packages/pr-report/dist/index.js | 21 +++++++++++---------- packages/pr-report/src/report.test.ts | 3 +++ packages/pr-report/src/report.ts | 4 ++-- 5 files changed, 20 insertions(+), 16 deletions(-) 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..241620d4 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -6762,7 +6762,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/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> ")}`); } From 6d92cd576a192ef7b21178229fc200cfc08dbf26 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Fri, 15 May 2026 10:50:36 +0900 Subject: [PATCH 2/5] fix(cli): strip runtime metadata from prompts Why Agent runtimes can prepend environment metadata such as to the text reported as a user prompt. That metadata is useful to the model but should not become durable Agent Note data or appear in PR Reports. User impact PR Report, Dashboard, show, and why now display the user's actual request without leading runtime metadata while still dropping standalone system-injected messages. Verification npm run typecheck --workspace packages/cli npm run lint --workspace packages/cli npm run build --workspace packages/cli npm test --workspace packages/cli Release note: Hide injected runtime metadata from recorded prompts while preserving the real user request. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture.md | 2 + packages/cli/dist/cli.js | 98 ++++++++++++++------- packages/cli/src/agents/claude.test.ts | 41 +++++++++ packages/cli/src/agents/claude.ts | 34 +++---- packages/cli/src/agents/codex.test.ts | 32 +++++++ packages/cli/src/agents/codex.ts | 11 ++- packages/cli/src/agents/cursor.test.ts | 30 +++++++ packages/cli/src/agents/cursor.ts | 19 ++-- packages/cli/src/agents/gemini.test.ts | 39 ++++++++ packages/cli/src/agents/gemini.ts | 11 ++- packages/cli/src/agents/prompt-text.test.ts | 55 ++++++++++++ packages/cli/src/agents/prompt-text.ts | 40 +++++++++ 14 files changed, 348 insertions(+), 68 deletions(-) create mode 100644 packages/cli/src/agents/prompt-text.test.ts create mode 100644 packages/cli/src/agents/prompt-text.ts 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/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 241620d4..1fc865d2 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -358,6 +358,37 @@ function isAgentNoteHookCommand(command2, agentName, options = {}) { return options.allowMissingAgent === true && agentFlag === null; } +// src/agents/prompt-text.ts +var SYSTEM_PROMPT_PREFIXES = ["]*)?>[\s\S]*?<\/environment_context>\s*/i; +var LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE = /^\s*]*)?\/>\s*/i; +function isSystemInjectedPrompt(prompt) { + for (const prefix of SYSTEM_PROMPT_PREFIXES) { + if (prompt.startsWith(prefix)) { + const next = prompt[prefix.length]; + if (next === ">" || next === " " || next === "\n" || next === void 0) { + return true; + } + } + } + return false; +} +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 +473,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 +585,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 +674,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 +1100,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 +1165,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 +1371,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 +1423,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 +1562,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 +1940,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 +2035,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 }; 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..bb054130 --- /dev/null +++ b/packages/cli/src/agents/prompt-text.test.ts @@ -0,0 +1,55 @@ +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("drops system-injected prompts after leading environment metadata", () => { + const prompt = normalizeUserPromptText( + [ + "", + "Asia/Tokyo", + "", + "Keep going.", + ].join("\n"), + ); + + assert.equal(prompt, null); + }); + + 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..f22a4959 --- /dev/null +++ b/packages/cli/src/agents/prompt-text.ts @@ -0,0 +1,40 @@ +const SYSTEM_PROMPT_PREFIXES = ["]*)?>[\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 { + for (const prefix of SYSTEM_PROMPT_PREFIXES) { + if (prompt.startsWith(prefix)) { + const next = prompt[prefix.length]; + if (next === ">" || next === " " || next === "\n" || next === undefined) { + return true; + } + } + } + return false; +} + +/** 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; +} From fbe54f29d8eb6afa1271c8629f6391fb7315d76f Mon Sep 17 00:00:00 2001 From: wasabeef Date: Fri, 15 May 2026 11:54:21 +0900 Subject: [PATCH 3/5] fix(cli): harden system prompt filtering Why Runtime system messages can appear as self-closing or differently cased tags. The prompt sanitizer should treat those as metadata while preserving real prompts that merely mention similar tag names. User impact Agent Note avoids recording standalone runtime/system prompts in a few additional edge cases without hiding user-authored prompt text. Verification npm run typecheck --workspace packages/cli npm run lint --workspace packages/cli npm run build --workspace packages/cli node --import tsx/esm --test src/agents/prompt-text.test.ts src/agents/claude.test.ts Release note: skip --- packages/cli/dist/cli.js | 12 ++---------- packages/cli/src/agents/prompt-text.test.ts | 5 +++++ packages/cli/src/agents/prompt-text.ts | 13 +++---------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 1fc865d2..83c42d23 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -359,19 +359,11 @@ function isAgentNoteHookCommand(command2, agentName, options = {}) { } // src/agents/prompt-text.ts -var SYSTEM_PROMPT_PREFIXES = ["]*)?\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) { - for (const prefix of SYSTEM_PROMPT_PREFIXES) { - if (prompt.startsWith(prefix)) { - const next = prompt[prefix.length]; - if (next === ">" || next === " " || next === "\n" || next === void 0) { - return true; - } - } - } - return false; + return SYSTEM_PROMPT_TAG_RE.test(prompt); } function stripLeadingEnvironmentContext(prompt) { let next = prompt; diff --git a/packages/cli/src/agents/prompt-text.test.ts b/packages/cli/src/agents/prompt-text.test.ts index bb054130..8b75b29f 100644 --- a/packages/cli/src/agents/prompt-text.test.ts +++ b/packages/cli/src/agents/prompt-text.test.ts @@ -39,6 +39,11 @@ describe("normalizeUserPromptText", () => { 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 strip environment_context text in the middle of a real prompt", () => { const prompt = normalizeUserPromptText( "Explain why appears in the PR report.", diff --git a/packages/cli/src/agents/prompt-text.ts b/packages/cli/src/agents/prompt-text.ts index f22a4959..08a26d36 100644 --- a/packages/cli/src/agents/prompt-text.ts +++ b/packages/cli/src/agents/prompt-text.ts @@ -1,19 +1,12 @@ -const SYSTEM_PROMPT_PREFIXES = ["]*)?\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 { - for (const prefix of SYSTEM_PROMPT_PREFIXES) { - if (prompt.startsWith(prefix)) { - const next = prompt[prefix.length]; - if (next === ">" || next === " " || next === "\n" || next === undefined) { - return true; - } - } - } - return false; + return SYSTEM_PROMPT_TAG_RE.test(prompt); } /** Strip leading runtime metadata blocks while preserving the user's actual prompt. */ From 80f98f9dbdcf352f7314829b2d0628a0902a8ce1 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Fri, 15 May 2026 12:04:42 +0900 Subject: [PATCH 4/5] fix(cli): preserve prompts after system tags Why CodeRabbit caught that the system prompt filter matched any prompt starting with a system tag. The intended contract is narrower: only standalone injected tag payloads should be skipped. User impact Agent Note no longer drops legitimate user text that follows a system-looking tag, while standalone injected messages are still filtered. Verification npm run typecheck --workspace packages/cli npm run lint --workspace packages/cli npm run build --workspace packages/cli node --import tsx/esm --test src/agents/prompt-text.test.ts src/agents/claude.test.ts Release note: skip --- packages/cli/dist/cli.js | 2 +- packages/cli/src/agents/prompt-text.test.ts | 11 +++++++++++ packages/cli/src/agents/prompt-text.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 83c42d23..d459ec2a 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -359,7 +359,7 @@ function isAgentNoteHookCommand(command2, agentName, options = {}) { } // src/agents/prompt-text.ts -var SYSTEM_PROMPT_TAG_RE = /^\s*<(?:task-notification|system-reminder|teammate-message)(?:\s[^>]*)?\s*\/?>/i; +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) { diff --git a/packages/cli/src/agents/prompt-text.test.ts b/packages/cli/src/agents/prompt-text.test.ts index 8b75b29f..bc077d7c 100644 --- a/packages/cli/src/agents/prompt-text.test.ts +++ b/packages/cli/src/agents/prompt-text.test.ts @@ -44,6 +44,17 @@ describe("normalizeUserPromptText", () => { 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.", diff --git a/packages/cli/src/agents/prompt-text.ts b/packages/cli/src/agents/prompt-text.ts index 08a26d36..a64564a9 100644 --- a/packages/cli/src/agents/prompt-text.ts +++ b/packages/cli/src/agents/prompt-text.ts @@ -1,5 +1,5 @@ const SYSTEM_PROMPT_TAG_RE = - /^\s*<(?:task-notification|system-reminder|teammate-message)(?:\s[^>]*)?\s*\/?>/i; + /^\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; From d8ab1b70c3599d1bd8926814e7d70f63bb41cd22 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Fri, 15 May 2026 14:03:00 +0900 Subject: [PATCH 5/5] test(cli): cover self-closing environment metadata Why CodeRabbit noted that the prompt sanitizer had a dedicated self-closing environment_context path without direct test coverage. User impact Keeps the prompt sanitizer regression suite explicit for self-closing runtime metadata while leaving behavior unchanged. Verification npm run lint --workspace packages/cli node --import tsx/esm --test src/agents/prompt-text.test.ts Release note: skip --- packages/cli/src/agents/prompt-text.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/agents/prompt-text.test.ts b/packages/cli/src/agents/prompt-text.test.ts index bc077d7c..97bd8d23 100644 --- a/packages/cli/src/agents/prompt-text.test.ts +++ b/packages/cli/src/agents/prompt-text.test.ts @@ -26,6 +26,14 @@ describe("normalizeUserPromptText", () => { 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( [