Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<task-notification>`, `<system-reminder>`, `<teammate-message>`) 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 `<environment_context>` while preserving the actual user request, and drops standalone system-injected messages (`<task-notification>`, `<system-reminder>`, `<teammate-message>`) 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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<task-notification>`, `<system-reminder>`, `<teammate-message>`) 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 `<environment_context>` while preserving the actual user request, and drops standalone system-injected messages (`<task-notification>`, `<system-reminder>`, `<teammate-message>`) 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
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<environment_context>` are stripped while the actual user request is preserved, and standalone system-injected messages such as `<task-notification>`, `<system-reminder>`, and `<teammate-message>` 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/`)**
Expand Down
6 changes: 3 additions & 3 deletions docs/knowledge/prompt-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,13 @@ sentence boundary:
`contexts[]` がある場合は、prompt の前に 1 つの `📝 Context` block として表示します。

```md
> **📝 Context**
**📝 Context**
> ...
>
> **🧑 Prompt**
**🧑 Prompt**
> ...
>
> **🤖 Response**
**🤖 Response**
> ...
```

Expand Down
92 changes: 60 additions & 32 deletions packages/cli/dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*<environment_context(?:\s[^>]*)?>[\s\S]*?<\/environment_context>\s*/i;
var LEADING_SELF_CLOSING_ENVIRONMENT_CONTEXT_RE = /^\s*<environment_context(?:\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",
Expand Down Expand Up @@ -442,18 +465,6 @@ function isValidTranscriptPath(p) {
const normalized = resolve(p);
return normalized === base || normalized.startsWith(`${base}${sep}`);
}
var SYSTEM_PROMPT_PREFIXES = ["<task-notification", "<system-reminder", "<teammate-message"];
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 isGitCommit(cmd) {
return findGitCommitCommand(cmd) !== null;
}
Expand Down Expand Up @@ -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 ?? "";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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
});
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/agents/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"<environment_context>\n<current_date>2026-05-15</current_date>\n</environment_context>\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({
Expand Down Expand Up @@ -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: "<environment_context>\n<timezone>Asia/Tokyo</timezone>\n</environment_context>\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 = [
Expand Down
34 changes: 14 additions & 20 deletions packages/cli/src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -100,22 +101,6 @@ interface ClaudeEvent {
transcript_path?: string;
}

/** Known system-injected message prefixes. Match `<tag>` or `<tag ` (with attributes). */
const SYSTEM_PROMPT_PREFIXES = ["<task-notification", "<system-reminder", "<teammate-message"];

function isSystemInjectedPrompt(prompt: string): boolean {
for (const prefix of SYSTEM_PROMPT_PREFIXES) {
if (prompt.startsWith(prefix)) {
const next = prompt[prefix.length];
// Must be followed by '>' 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;
Expand Down Expand Up @@ -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 ?? "";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading