@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/LICENSE +21 -0
- package/README.md +196 -0
- package/docs/cc/hook-integration.md +125 -0
- package/docs/obsidian/06-official-cli-research.md +178 -0
- package/docs/obsidian/README.md +19 -0
- package/package.json +34 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/claude-settings.ts +102 -0
- package/src/cli.ts +419 -0
- package/src/config.ts +41 -0
- package/src/detect.ts +88 -0
- package/src/hook.ts +74 -0
- package/src/note-writer.ts +255 -0
- package/src/obsidian-cli.ts +86 -0
- package/src/schema/daily-note.ts +84 -0
- package/src/schema/hook-input.ts +108 -0
- package/src/schema/pretty-prompt.ts +91 -0
- package/src/types.ts +23 -0
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
|
+
}
|