@albireo3754/agentlog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hook.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * AgentLog hook entry point.
3
+ *
4
+ * Invoked by Claude Code UserPromptSubmit hook via stdin JSON.
5
+ * Reads prompt, determines Daily Note path, delegates to note-writer.
6
+ *
7
+ * Design: fail silently โ€” never interrupt Claude Code.
8
+ */
9
+
10
+ import { loadConfig } from "./config.js";
11
+ import { parseHookInput } from "./schema/hook-input.js";
12
+ import { cwdToProject } from "./schema/daily-note.js";
13
+ import { prettyPrompt } from "./schema/pretty-prompt.js";
14
+ import { appendEntry } from "./note-writer.js";
15
+
16
+ /** Read all stdin as a string. Works with both Bun and Node.js. */
17
+ function readStdin(): Promise<string> {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks: Buffer[] = [];
20
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
21
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
22
+ process.stdin.on("error", reject);
23
+ });
24
+ }
25
+
26
+ async function main(): Promise<void> {
27
+ // 1. Load config โ€” if absent, hint and exit (not initialized)
28
+ const config = loadConfig();
29
+ if (!config) {
30
+ process.stderr.write("[agentlog] not initialized. Run: agentlog init ~/path/to/vault\n");
31
+ return;
32
+ }
33
+
34
+ // 2. Read stdin (cross-runtime: works with both Bun and Node.js)
35
+ const raw = await readStdin();
36
+
37
+ // 3. Parse hook input
38
+ let parsed;
39
+ try {
40
+ parsed = parseHookInput(raw);
41
+ } catch (err) {
42
+ process.stderr.write(`[agentlog] parse error: ${err}\n`);
43
+ return;
44
+ }
45
+
46
+ // 4. Build time string
47
+ const now = new Date();
48
+ const hh = String(now.getHours()).padStart(2, "0");
49
+ const mm = String(now.getMinutes()).padStart(2, "0");
50
+ const time = `${hh}:${mm}`;
51
+
52
+ // 5. Sanitize prompt โ€” skip system noise
53
+ const prompt = prettyPrompt(parsed.prompt);
54
+ if (!prompt) return;
55
+
56
+ // 6. Append entry to Daily Note
57
+ const entry = {
58
+ time,
59
+ prompt,
60
+ sessionId: parsed.sessionId,
61
+ project: cwdToProject(parsed.cwd),
62
+ cwd: parsed.cwd,
63
+ };
64
+
65
+ try {
66
+ appendEntry(config, entry, now);
67
+ } catch (err) {
68
+ process.stderr.write(`[agentlog] write error: ${err}\n`);
69
+ }
70
+ }
71
+
72
+ main().catch((err) => {
73
+ process.stderr.write(`[agentlog] fatal: ${err}\n`);
74
+ });
@@ -0,0 +1,255 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import type { AgentLogConfig, LogEntry, WriteResult } from "./types.js";
4
+ import {
5
+ dailyNoteFileName,
6
+ buildAgentLogEntry,
7
+ buildSessionDivider,
8
+ buildLatestLine,
9
+ buildProjectHeader,
10
+ buildProjectMetadata,
11
+ } from "./schema/daily-note.js";
12
+
13
+ /** Zero-pads a number to 2 digits. */
14
+ function pad2(n: number): string {
15
+ return String(n).padStart(2, "0");
16
+ }
17
+
18
+ /** Returns daily note file path for a given date. */
19
+ export function dailyNotePath(config: AgentLogConfig, date: Date): string {
20
+ if (config.plain) {
21
+ const yyyy = date.getFullYear();
22
+ const mm = pad2(date.getMonth() + 1);
23
+ const dd = pad2(date.getDate());
24
+ return join(config.vault, `${yyyy}-${mm}-${dd}.md`);
25
+ }
26
+ return join(config.vault, "Daily", dailyNoteFileName(date));
27
+ }
28
+
29
+ /**
30
+ * Appends a log entry to the Daily Note.
31
+ *
32
+ * For plain mode: simple append (unchanged behavior).
33
+ * For normal mode: session-grouped ## AgentLog section.
34
+ * - Groups entries by project (derived from cwd).
35
+ * - Inserts session divider when session_id changes within a project.
36
+ * - Keeps a pinned "> ๐Ÿ•" latest-entry line at the top of ## AgentLog.
37
+ */
38
+ export function appendEntry(
39
+ config: AgentLogConfig,
40
+ entry: LogEntry,
41
+ date: Date = new Date()
42
+ ): WriteResult {
43
+ const filePath = dailyNotePath(config, date);
44
+
45
+ if (config.plain) {
46
+ return appendPlain(filePath, entry, date);
47
+ }
48
+
49
+ const created = !existsSync(filePath);
50
+ if (created) {
51
+ mkdirSync(dirname(filePath), { recursive: true });
52
+ }
53
+
54
+ const content = created ? "" : readFileSync(filePath, "utf-8");
55
+ const newContent = insertIntoAgentLogSection(content, entry);
56
+ writeFileSync(filePath, newContent, "utf-8");
57
+ return { filePath, created, section: "agentlog" };
58
+ }
59
+
60
+ /**
61
+ * Insert entry into ## AgentLog section with session-grouped subsections.
62
+ *
63
+ * Output structure:
64
+ * ## AgentLog
65
+ * > ๐Ÿ• HH:MM โ€” project โ€บ prompt โ† latest entry (always updated)
66
+ *
67
+ * #### project ยท HH:MM <!-- cwd ses --> โ† one section per cwd
68
+ * - HH:MM entry
69
+ * - - - - (ses_XXXXXXXX) โ† divider when session changes
70
+ * - HH:MM entry
71
+ */
72
+ function insertIntoAgentLogSection(content: string, entry: LogEntry): string {
73
+ const sessionShort = entry.sessionId.slice(0, 8);
74
+ const entryLine = buildAgentLogEntry(entry.time, entry.prompt);
75
+ const latestLine = buildLatestLine(entry.time, entry.project, entry.prompt);
76
+
77
+ const lines = content.split("\n");
78
+
79
+ // 1. Find or create ## AgentLog
80
+ let agentLogIdx = lines.findIndex((l) => l === "## AgentLog");
81
+ if (agentLogIdx === -1) {
82
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
83
+ lines.push("");
84
+ }
85
+ lines.push("## AgentLog");
86
+ agentLogIdx = lines.length - 1;
87
+ }
88
+
89
+ // Find end of ## AgentLog section (next ## heading or EOF)
90
+ let agentLogEnd = lines.length;
91
+ for (let i = agentLogIdx + 1; i < lines.length; i++) {
92
+ if (/^## [^#]/.test(lines[i])) {
93
+ agentLogEnd = i;
94
+ break;
95
+ }
96
+ }
97
+
98
+ // 2. Find > ๐Ÿ• latest line (first non-blank line after ## AgentLog)
99
+ let latestLineIdx = -1;
100
+ for (let i = agentLogIdx + 1; i < agentLogEnd; i++) {
101
+ if (lines[i] === "") continue;
102
+ if (lines[i].startsWith("> ๐Ÿ•")) {
103
+ latestLineIdx = i;
104
+ }
105
+ break; // only check the first non-blank line
106
+ }
107
+
108
+ // 3. Find #### project subsection matching entry.cwd
109
+ // New format: "#### HH:MM ยท project" + next line "<!-- cwd=<path> -->"
110
+ // Legacy meta: "#### HH:MM ยท project" + next line "<!-- cwd=<path> ses=<short> -->"
111
+ // Legacy inline: "#### project ยท HH:MM <!-- cwd=<path> ses=<short> -->"
112
+ const metaRe = /^<!-- cwd=(.+?) -->$/;
113
+ const legacyMetaRe = /^<!-- cwd=(.+?) ses=([\w-]+) -->$/;
114
+ const legacyHeaderRe = /^#### .+ <!-- cwd=(.+?) ses=([\w-]+) -->$/;
115
+ // Match both new [[ses_...]] and legacy (ses_...) formats for backward compat
116
+ const dividerRe = /^- - - - (?:\[\[ses_([\w-]+)\]\]|\(ses_([\w-]+)\))$/;
117
+
118
+ let projectIdx = -1;
119
+ let projectMetaIdx = -1; // -1 means legacy inline format (no separate metadata line)
120
+ let legacySes = ""; // ses from legacy metadata, used as fallback when no dividers exist
121
+ let existingTime = entry.time;
122
+
123
+ for (let i = agentLogIdx + 1; i < agentLogEnd; i++) {
124
+ if (!lines[i].startsWith("#### ")) continue;
125
+
126
+ // Try new format: metadata on next line (no ses)
127
+ const meta = lines[i + 1]?.match(metaRe);
128
+ if (meta) {
129
+ const [, storedCwd] = meta;
130
+ if (storedCwd !== entry.cwd) continue;
131
+ projectIdx = i;
132
+ projectMetaIdx = i + 1;
133
+ const timeMatch = lines[i].match(/^#### (\d{2}:\d{2}) /);
134
+ if (timeMatch) existingTime = timeMatch[1];
135
+ break;
136
+ }
137
+
138
+ // Try legacy format: metadata with ses on next line
139
+ const legacyMeta = lines[i + 1]?.match(legacyMetaRe);
140
+ if (legacyMeta) {
141
+ const [, storedCwd, commentSes] = legacyMeta;
142
+ if (storedCwd !== entry.cwd) continue;
143
+ projectIdx = i;
144
+ projectMetaIdx = i + 1;
145
+ legacySes = commentSes;
146
+ const timeMatch = lines[i].match(/^#### (\d{2}:\d{2}) /);
147
+ if (timeMatch) existingTime = timeMatch[1];
148
+ break;
149
+ }
150
+
151
+ // Try legacy inline format
152
+ const legacy = lines[i].match(legacyHeaderRe);
153
+ if (legacy) {
154
+ const [, storedCwd, commentSes] = legacy;
155
+ if (storedCwd !== entry.cwd) continue;
156
+ projectIdx = i;
157
+ projectMetaIdx = -1;
158
+ legacySes = commentSes;
159
+ const timeMatch = lines[i].match(/ยท (\d{2}:\d{2}) /);
160
+ if (timeMatch) existingTime = timeMatch[1];
161
+ break;
162
+ }
163
+ }
164
+
165
+ // 4. Insert entry
166
+ if (projectIdx === -1) {
167
+ // New project: create #### section with initial session divider at end of ## AgentLog.
168
+ // The initial divider anchors the starting session so subsequent entries can compare.
169
+ const header = buildProjectHeader(entry.project, entry.time);
170
+ const meta = buildProjectMetadata(entry.cwd);
171
+ const prevLine = lines[agentLogEnd - 1];
172
+ const newSection: string[] = [];
173
+ if (prevLine !== "" && prevLine !== "## AgentLog") {
174
+ newSection.push("");
175
+ }
176
+ newSection.push(header, meta, buildSessionDivider(entry.sessionId), entryLine);
177
+ lines.splice(agentLogEnd, 0, ...newSection);
178
+ } else {
179
+ // Existing project: find end of this subsection
180
+ let subsectionEnd = agentLogEnd;
181
+ for (let i = projectIdx + 1; i < agentLogEnd; i++) {
182
+ if (lines[i].startsWith("#### ")) {
183
+ subsectionEnd = i;
184
+ break;
185
+ }
186
+ }
187
+
188
+ const firstContentIdx = projectMetaIdx === -1 ? projectIdx + 1 : projectMetaIdx + 1;
189
+ let insertAt = subsectionEnd;
190
+ while (insertAt > firstContentIdx && lines[insertAt - 1] === "") {
191
+ insertAt--;
192
+ }
193
+
194
+ // Determine current session from the last divider in this subsection.
195
+ // Falls back to legacySes (from old metadata format) if no dividers found.
196
+ let currentSes = "";
197
+ for (let i = firstContentIdx; i < subsectionEnd; i++) {
198
+ const m = lines[i].match(dividerRe);
199
+ if (m) currentSes = m[1] ?? m[2]; // group 1: new [[ses_...]], group 2: legacy (ses_...)
200
+ }
201
+ if (!currentSes) currentSes = legacySes;
202
+
203
+ if (currentSes !== sessionShort) {
204
+ // Session changed: insert divider + entry.
205
+ lines.splice(insertAt, 0, buildSessionDivider(entry.sessionId), entryLine);
206
+ // Migrate legacy metadata format to new format (remove ses=).
207
+ if (projectMetaIdx !== -1 && lines[projectMetaIdx].match(legacyMetaRe)) {
208
+ lines[projectMetaIdx] = buildProjectMetadata(entry.cwd);
209
+ } else if (projectMetaIdx === -1) {
210
+ // Legacy inline header โ†’ migrate to split format
211
+ lines[projectIdx] = buildProjectHeader(entry.project, existingTime);
212
+ lines.splice(projectIdx + 1, 0, buildProjectMetadata(entry.cwd));
213
+ }
214
+ } else {
215
+ lines.splice(insertAt, 0, entryLine);
216
+ }
217
+ }
218
+
219
+ // 5. Update > ๐Ÿ• latest line
220
+ // Note: latestLineIdx is always before insertAt, so it's unaffected by the splice above.
221
+ if (latestLineIdx !== -1) {
222
+ lines[latestLineIdx] = latestLine;
223
+ } else {
224
+ // Insert after ## AgentLog
225
+ if (lines[agentLogIdx + 1] === "") {
226
+ lines.splice(agentLogIdx + 1, 0, latestLine);
227
+ } else {
228
+ lines.splice(agentLogIdx + 1, 0, latestLine, "");
229
+ }
230
+ }
231
+
232
+ return lines.join("\n");
233
+ }
234
+
235
+ /** Plain mode: simple append without session grouping. */
236
+ function appendPlain(filePath: string, entry: LogEntry, date: Date): WriteResult {
237
+ const created = !existsSync(filePath);
238
+
239
+ if (created) {
240
+ mkdirSync(dirname(filePath), { recursive: true });
241
+ const yyyy = date.getFullYear();
242
+ const mm = pad2(date.getMonth() + 1);
243
+ const dd = pad2(date.getDate());
244
+ const header = `# ${yyyy}-${mm}-${dd}\n`;
245
+ writeFileSync(filePath, `${header}- ${entry.time} ${entry.prompt}\n`, "utf-8");
246
+ return { filePath, created: true, section: "plain" };
247
+ }
248
+
249
+ const content = readFileSync(filePath, "utf-8");
250
+ const appended = content.endsWith("\n")
251
+ ? content + `- ${entry.time} ${entry.prompt}\n`
252
+ : content + `\n- ${entry.time} ${entry.prompt}\n`;
253
+ writeFileSync(filePath, appended, "utf-8");
254
+ return { filePath, created: false, section: "plain" };
255
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Obsidian CLI (1.12+) detection and execution.
3
+ * Encapsulates all Obsidian CLI interaction so other modules
4
+ * only need to call these functions.
5
+ */
6
+
7
+ import { spawnSync } from "child_process";
8
+ import { existsSync } from "fs";
9
+
10
+ /** Known macOS paths where Obsidian CLI binary may live (PATH may not include these). */
11
+ const MACOS_CLI_PATHS = [
12
+ "/Applications/Obsidian.app/Contents/MacOS/obsidian",
13
+ ];
14
+
15
+ type CliBinCacheState =
16
+ | { status: "unresolved" }
17
+ | { status: "resolved"; bin: string }
18
+ | { status: "not-found" };
19
+
20
+ /** Cached CLI resolution state for PATH/macos fallback lookup. */
21
+ let _cachedBinState: CliBinCacheState = { status: "unresolved" };
22
+
23
+ function envOverrideBin(): string | null {
24
+ const raw = process.env.OBSIDIAN_BIN?.trim();
25
+ return raw ? raw : null;
26
+ }
27
+
28
+ /**
29
+ * Resolve the `obsidian` CLI binary path.
30
+ * Resolution order:
31
+ * 1) OBSIDIAN_BIN env override
32
+ * 2) `which obsidian`
33
+ * 3) known macOS app bundle paths
34
+ */
35
+ export function resolveCliBin(): string | null {
36
+ const override = envOverrideBin();
37
+ if (override) return override;
38
+
39
+ if (_cachedBinState.status === "resolved") return _cachedBinState.bin;
40
+ if (_cachedBinState.status === "not-found") return null;
41
+
42
+ const which = spawnSync("which", ["obsidian"], { encoding: "utf-8", timeout: 3000 });
43
+ if (which.status === 0 && which.stdout.trim()) {
44
+ _cachedBinState = { status: "resolved", bin: which.stdout.trim() };
45
+ return _cachedBinState.bin;
46
+ }
47
+
48
+ if (process.platform === "darwin") {
49
+ for (const p of MACOS_CLI_PATHS) {
50
+ if (existsSync(p)) {
51
+ _cachedBinState = { status: "resolved", bin: p };
52
+ return _cachedBinState.bin;
53
+ }
54
+ }
55
+ }
56
+
57
+ _cachedBinState = { status: "not-found" };
58
+ return null;
59
+ }
60
+
61
+ /** Minimum Obsidian version that supports CLI */
62
+ export const MIN_CLI_VERSION = "1.12.4";
63
+
64
+ /** Extract version string from CLI stdout (last non-empty line). */
65
+ export function parseCliVersion(stdout: string): string | null {
66
+ const lines = stdout.trim().split("\n").filter(Boolean);
67
+ return (lines.at(-1) ?? "").trim() || null;
68
+ }
69
+
70
+ /**
71
+ * Compare two semver-like version strings (e.g. "1.12.4" >= "1.12.4").
72
+ * Returns true if `version` >= `minimum`.
73
+ */
74
+ export function isVersionAtLeast(version: string, minimum: string): boolean {
75
+ const parse = (v: string) => v.split(".").map((n) => parseInt(n, 10) || 0);
76
+ const ver = parse(version);
77
+ const min = parse(minimum);
78
+ for (let i = 0; i < Math.max(ver.length, min.length); i++) {
79
+ const a = ver[i] ?? 0;
80
+ const b = min[i] ?? 0;
81
+ if (a > b) return true;
82
+ if (a < b) return false;
83
+ }
84
+ return true; // equal
85
+ }
86
+
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Daily Note Schema โ€” Time block detection patterns
3
+ *
4
+ * Defines the Korean Obsidian Daily Note time-block format that agentlog
5
+ * appends log entries into.
6
+ */
7
+
8
+ /** Korean day names indexed by JS getDay() (0=Sunday) */
9
+ export const KO_DAYS = ["์ผ", "์›”", "ํ™”", "์ˆ˜", "๋ชฉ", "๊ธˆ", "ํ† "] as const;
10
+
11
+ /**
12
+ * Returns the Daily Note file name for a given date.
13
+ * Format: YYYY-MM-DD-์š”์ผ.md (e.g. 2026-03-01-์ผ.md)
14
+ */
15
+ export function dailyNoteFileName(date: Date): string {
16
+ const yyyy = date.getFullYear();
17
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
18
+ const dd = String(date.getDate()).padStart(2, "0");
19
+ const day = KO_DAYS[date.getDay()];
20
+ return `${yyyy}-${mm}-${dd}-${day}.md`;
21
+ }
22
+
23
+ /**
24
+ * Derives project display name from cwd.
25
+ * Always returns "parent/basename" (2-level, no special cases).
26
+ * E.g. "/Users/pray/work/js/agentlog" โ†’ "js/agentlog"
27
+ * "/Users/pray/worktrees/v5/gate" โ†’ "v5/gate"
28
+ */
29
+ export function cwdToProject(cwd: string): string {
30
+ const parts = cwd.replace(/\/$/, "").split("/").filter(Boolean);
31
+ if (parts.length >= 2) {
32
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
33
+ }
34
+ return parts[parts.length - 1] ?? cwd;
35
+ }
36
+
37
+ /**
38
+ * Entry line within a #### project section.
39
+ * Format: "- HH:MM prompt"
40
+ */
41
+ export function buildAgentLogEntry(time: string, prompt: string): string {
42
+ return `- ${time} ${prompt}`;
43
+ }
44
+
45
+ /**
46
+ * Session divider line inserted when session_id changes within a project section.
47
+ * Uses Obsidian wiki-link format so the session ID becomes a navigable link.
48
+ * Format: "- - - - [[ses_XXXXXXXX]]"
49
+ */
50
+ export function buildSessionDivider(sessionId: string): string {
51
+ return `- - - - [[ses_${sessionId.slice(0, 8)}]]`;
52
+ }
53
+
54
+ /**
55
+ * Latest entry blockquote pinned at top of ## AgentLog section.
56
+ * Format: "> ๐Ÿ• HH:MM โ€” project โ€บ prompt"
57
+ */
58
+ export function buildLatestLine(time: string, project: string, prompt: string): string {
59
+ return `> ๐Ÿ• ${time} โ€” ${project} โ€บ ${prompt}`;
60
+ }
61
+
62
+ /**
63
+ * Project subsection header line.
64
+ * Format: "#### HH:MM ยท project"
65
+ */
66
+ export function buildProjectHeader(
67
+ project: string,
68
+ time: string,
69
+ ): string {
70
+ return `#### ${time} ยท ${project}`;
71
+ }
72
+
73
+ /**
74
+ * Metadata comment line placed directly below the project header.
75
+ * Stores cwd (matching key) for section identification.
76
+ * Format: "<!-- cwd=<path> -->"
77
+ *
78
+ * Kept on a separate line so the #### heading remains visually clean.
79
+ * HTML comments are hidden in Obsidian reading view.
80
+ * Session tracking is done via - - - - (ses_XXXXXXXX) divider lines in content.
81
+ */
82
+ export function buildProjectMetadata(cwd: string): string {
83
+ return `<!-- cwd=${cwd} -->`;
84
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Hook Input Schema โ€” Source of Truth
3
+ *
4
+ * Claude Code sends this JSON payload via stdin when a UserPromptSubmit hook fires.
5
+ * All fields are validated at runtime by parseHookInput().
6
+ */
7
+
8
+ /** A single content part in the message */
9
+ export interface HookInputPart {
10
+ type: "text";
11
+ text: string;
12
+ }
13
+
14
+ /** The message object nested inside hook input */
15
+ export interface HookInputMessage {
16
+ content: string;
17
+ }
18
+
19
+ /**
20
+ * Full UserPromptSubmit hook payload from Claude Code.
21
+ *
22
+ * Required fields: hook_event_name, session_id, cwd
23
+ * Prompt is sourced from `prompt` (preferred) or `message.content` fallback.
24
+ */
25
+ export interface HookInput {
26
+ hook_event_name: "UserPromptSubmit";
27
+ session_id: string;
28
+ cwd: string;
29
+ /** Path to the session transcript JSONL file */
30
+ transcript_path?: string;
31
+ /** Claude Code permission mode (e.g. "default", "acceptEdits") */
32
+ permission_mode?: string;
33
+ /** Direct prompt text โ€” preferred source */
34
+ prompt?: string;
35
+ /** Nested message object โ€” fallback if prompt is absent */
36
+ message?: HookInputMessage;
37
+ /** Structured content parts โ€” secondary fallback */
38
+ parts?: HookInputPart[];
39
+ }
40
+
41
+ /** Parsed, normalized result after validating hook input */
42
+ export interface ParsedHookInput {
43
+ sessionId: string;
44
+ cwd: string;
45
+ prompt: string;
46
+ }
47
+
48
+ /**
49
+ * Parse and validate raw hook stdin JSON.
50
+ *
51
+ * Prompt extraction priority:
52
+ * 1. input.prompt
53
+ * 2. input.message.content
54
+ * 3. input.parts[0].text (first text part)
55
+ *
56
+ * @throws {Error} if JSON is invalid or required fields are missing
57
+ */
58
+ export function parseHookInput(raw: string): ParsedHookInput {
59
+ let input: unknown;
60
+ try {
61
+ input = JSON.parse(raw);
62
+ } catch {
63
+ throw new Error("Invalid JSON in hook stdin");
64
+ }
65
+
66
+ if (typeof input !== "object" || input === null) {
67
+ throw new Error("Hook input must be a JSON object");
68
+ }
69
+
70
+ const obj = input as Record<string, unknown>;
71
+
72
+ if (typeof obj["session_id"] !== "string" || !obj["session_id"]) {
73
+ throw new Error("Missing required field: session_id");
74
+ }
75
+ if (typeof obj["cwd"] !== "string" || !obj["cwd"]) {
76
+ throw new Error("Missing required field: cwd");
77
+ }
78
+
79
+ // Prompt extraction with fallback chain
80
+ let prompt: string | undefined;
81
+
82
+ if (typeof obj["prompt"] === "string" && obj["prompt"]) {
83
+ prompt = obj["prompt"];
84
+ } else if (
85
+ typeof obj["message"] === "object" &&
86
+ obj["message"] !== null &&
87
+ typeof (obj["message"] as Record<string, unknown>)["content"] === "string"
88
+ ) {
89
+ prompt = (obj["message"] as Record<string, unknown>)["content"] as string;
90
+ } else if (Array.isArray(obj["parts"])) {
91
+ const textPart = (obj["parts"] as HookInputPart[]).find(
92
+ (p) => p.type === "text" && typeof p.text === "string"
93
+ );
94
+ if (textPart) prompt = textPart.text;
95
+ }
96
+
97
+ if (!prompt) {
98
+ throw new Error(
99
+ "Cannot extract prompt: missing prompt, message.content, or parts[].text"
100
+ );
101
+ }
102
+
103
+ return {
104
+ sessionId: obj["session_id"] as string,
105
+ cwd: obj["cwd"] as string,
106
+ prompt,
107
+ };
108
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * prettyPrompt โ€” Sanitize raw hook prompt for Daily Note display.
3
+ *
4
+ * Handles: framework injections (skip), multi-line collapsing,
5
+ * XML/HTML stripping, markdown flattening, and length truncation.
6
+ *
7
+ * Returns null when the prompt should not be logged (system noise).
8
+ */
9
+
10
+ /** Prompts matching these are system/framework noise โ€” skip entirely. */
11
+ const SKIP_PATTERNS: RegExp[] = [
12
+ /^\[Request interrupted/,
13
+ /^<local-command-caveat>/,
14
+ /^<local-command-stdout>/,
15
+ /^<command-name>/,
16
+ /^<command-message>/,
17
+ /^<task-notification>/,
18
+ /^Base directory for this skill:/,
19
+ /^This session is being continued from a previous conversation/,
20
+ /^# \w.+ โ€” /,
21
+ /^Stop hook feedback:/,
22
+ /^\[AUTOPILOT/,
23
+ /^\[RALPH LOOP/,
24
+ /^\[MAGIC KEYWORD/,
25
+ ];
26
+
27
+ /**
28
+ * Collapse multi-line text into a summary.
29
+ * - 1-2 lines: join with space
30
+ * - 3+ lines: "first line (+N lines) last line"
31
+ * Empty lines after strip are excluded from count.
32
+ */
33
+ function collapseLines(text: string): string {
34
+ const lines = text.split("\n").map((l) => l.replace(/\s{2,}/g, " ").trim()).filter(Boolean);
35
+ if (lines.length <= 2) {
36
+ return lines.join(" ");
37
+ }
38
+ const skipped = lines.length - 2;
39
+ return `${lines[0]} (+${skipped} lines) ${lines[lines.length - 1]}`;
40
+ }
41
+
42
+ /**
43
+ * Sanitize a raw prompt string for single-line Daily Note display.
44
+ * Returns null if the prompt is system noise and should not be logged.
45
+ */
46
+ export function prettyPrompt(raw: string): string | null {
47
+ const trimmed = raw.trim();
48
+ if (!trimmed) return null;
49
+
50
+ // 1. Skip system/framework injections
51
+ if (SKIP_PATTERNS.some((p) => p.test(trimmed))) return null;
52
+
53
+ // 2. Extract real prompt from AgentLog feedback block
54
+ // e.g. "## AgentLog\n> ๐Ÿ• 14:24 โ€” project โ€บ actual prompt here"
55
+ const agentLogMatch = trimmed.match(
56
+ /^## AgentLog\n> [^\n]*?[โ€บยป]\s*(.+?)(?:\n|$)/m
57
+ );
58
+ if (agentLogMatch) return prettyPrompt(agentLogMatch[1]);
59
+
60
+ let text = trimmed;
61
+
62
+ // 3. Strip ANSI escape codes and control characters
63
+ text = text.replace(/\x1b\[[0-9;]*m/g, "");
64
+ text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
65
+
66
+ // 4. Strip HTML/XML comments (<!-- ... -->)
67
+ text = text.replace(/<!--[\s\S]*?-->/g, "");
68
+
69
+ // 5. Strip wrapping XML tags (e.g. <system-reminder>...</system-reminder>)
70
+ text = text.replace(/<\/?[\w-]+>/g, "");
71
+
72
+ // 6. Strip markdown headings (##, ###, ####)
73
+ text = text.replace(/^#{1,6}\s+/gm, "");
74
+
75
+ // 7. Strip blockquote markers
76
+ text = text.replace(/^>\s*/gm, "");
77
+
78
+ // 8. Strip code fence markers
79
+ text = text.replace(/^```\w*$/gm, "");
80
+
81
+ // 9. Collapse multi-line: first line + (+N lines) + last line
82
+ text = collapseLines(text);
83
+
84
+ // 10. Trim again after all transformations
85
+ text = text.trim();
86
+
87
+ // 11. Too short after cleanup = noise
88
+ if (text.length < 2) return null;
89
+
90
+ return text;
91
+ }