agent-hours 0.3.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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Hourly worklog extraction — deterministic, no LLM calls (hard constraint:
3
+ * the CLI never spends API money). Pulls billing-relevant evidence per hour
4
+ * from the JSONLs: typed prompts, edited files, commands, commits, and the
5
+ * away_summary texts Claude Code itself wrote (free LLM summaries!).
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { hourKey } from "./core.js";
10
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
11
+ function excerpt(text, max = 90) {
12
+ const clean = text.replace(/\s+/g, " ").trim();
13
+ return clean.length > max ? clean.slice(0, max - 1) + "…" : clean;
14
+ }
15
+ function condenseCommand(cmd) {
16
+ return excerpt(cmd, 70);
17
+ }
18
+ function jsonlFiles(projectDir) {
19
+ let entries;
20
+ try {
21
+ entries = fs.readdirSync(projectDir, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const files = entries
27
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
28
+ .map((e) => path.join(projectDir, e.name));
29
+ for (const dir of entries.filter((e) => e.isDirectory())) {
30
+ const subDir = path.join(projectDir, dir.name, "subagents");
31
+ try {
32
+ for (const f of fs.readdirSync(subDir)) {
33
+ if (f.endsWith(".jsonl"))
34
+ files.push(path.join(subDir, f));
35
+ }
36
+ }
37
+ catch {
38
+ /* no subagents dir */
39
+ }
40
+ }
41
+ return files.sort();
42
+ }
43
+ /** Collects per-hour worklog evidence. Keys: "YYYY-MM-DD HH:00" (local). */
44
+ export function collectWorklog(projectDir, sinceMs, untilMs, tzOffsetHours) {
45
+ const hours = new Map();
46
+ const bucket = (ts) => {
47
+ const key = hourKey(ts, tzOffsetHours);
48
+ let b = hours.get(key);
49
+ if (!b) {
50
+ b = { prompts: [], filesEdited: new Set(), commands: [], commits: [], awaySummaries: [] };
51
+ hours.set(key, b);
52
+ }
53
+ return b;
54
+ };
55
+ for (const file of jsonlFiles(projectDir)) {
56
+ const isSubagent = file.includes(`${path.sep}subagents${path.sep}`);
57
+ let raw;
58
+ try {
59
+ raw = fs.readFileSync(file, "utf8");
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ for (const line of raw.split("\n")) {
65
+ if (!line.trim())
66
+ continue;
67
+ let r;
68
+ try {
69
+ r = JSON.parse(line);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ const tsStr = r["timestamp"];
75
+ if (typeof tsStr !== "string")
76
+ continue;
77
+ const ts = Date.parse(tsStr);
78
+ if (Number.isNaN(ts) || ts < sinceMs || ts > untilMs)
79
+ continue;
80
+ const type = r["type"];
81
+ // Human prompts (top-level transcripts only — subagent "user" msgs are machine)
82
+ if (!isSubagent && r["isSidechain"] !== true) {
83
+ if (type === "user" && !r["isMeta"] && !r["isCompactSummary"]) {
84
+ const src = r["promptSource"];
85
+ if (src === "typed" || src === undefined) {
86
+ const msg = r["message"];
87
+ const c = msg?.["content"];
88
+ let text = null;
89
+ if (typeof c === "string")
90
+ text = c;
91
+ else if (Array.isArray(c)) {
92
+ const t = c.find((i) => i && typeof i === "object" && i["type"] === "text");
93
+ const hasToolResult = c.some((i) => i && typeof i === "object" && i["type"] === "tool_result");
94
+ if (t && !hasToolResult)
95
+ text = String(t["text"] ?? "");
96
+ }
97
+ if (text &&
98
+ !text.startsWith("[Request interrupted") &&
99
+ !text.trimStart().startsWith("<command-") &&
100
+ !text.trimStart().startsWith("<local-command")) {
101
+ bucket(ts).prompts.push(excerpt(text));
102
+ }
103
+ }
104
+ }
105
+ if (type === "queue-operation" &&
106
+ r["operation"] === "enqueue" &&
107
+ typeof r["content"] === "string" &&
108
+ !r["content"].trimStart().startsWith("<task-notification")) {
109
+ bucket(ts).prompts.push(excerpt(r["content"]));
110
+ }
111
+ if (type === "system" && r["subtype"] === "away_summary" && typeof r["content"] === "string") {
112
+ bucket(ts).awaySummaries.push(excerpt(r["content"], 200));
113
+ }
114
+ }
115
+ // Tool activity counts from BOTH main and subagent transcripts —
116
+ // subagent edits are real work product.
117
+ if (type === "assistant") {
118
+ const msg = r["message"];
119
+ const c = msg?.["content"];
120
+ if (!Array.isArray(c))
121
+ continue;
122
+ for (const item of c) {
123
+ if (!item || typeof item !== "object")
124
+ continue;
125
+ const it = item;
126
+ if (it["type"] !== "tool_use")
127
+ continue;
128
+ const name = String(it["name"] ?? "");
129
+ const input = (it["input"] ?? {});
130
+ if (EDIT_TOOLS.has(name) && typeof input["file_path"] === "string") {
131
+ bucket(ts).filesEdited.add(input["file_path"]);
132
+ }
133
+ else if (name === "Bash" && typeof input["command"] === "string") {
134
+ const cmd = input["command"];
135
+ if (/git commit/.test(cmd)) {
136
+ // plain -m "msg" first; heredoc style (-m "$(cat <<EOF ... )")
137
+ // falls through to the first heredoc line as the subject
138
+ let m = cmd.match(/-m\s+["']([^"'$][^"']*)/);
139
+ if (!m)
140
+ m = cmd.match(/<<\s*'?EOF'?\s*\n\s*([^\n]+)/);
141
+ if (m) {
142
+ bucket(ts).commits.push(excerpt(m[1], 80));
143
+ continue;
144
+ }
145
+ }
146
+ bucket(ts).commands.push(condenseCommand(cmd));
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return hours;
153
+ }
154
+ /** Shortens file paths to a common-sense display form (basename + parent). */
155
+ export function shortPath(p) {
156
+ const parts = p.split("/");
157
+ return parts.length > 2 ? parts.slice(-2).join("/") : p;
158
+ }
159
+ /** Merges several hour buckets into one (for day/project aggregation). */
160
+ export function mergeLogs(logs) {
161
+ const out = {
162
+ prompts: [],
163
+ filesEdited: new Set(),
164
+ commands: [],
165
+ commits: [],
166
+ awaySummaries: [],
167
+ };
168
+ for (const l of logs) {
169
+ out.prompts.push(...l.prompts);
170
+ for (const f of l.filesEdited)
171
+ out.filesEdited.add(f);
172
+ out.commands.push(...l.commands);
173
+ out.commits.push(...l.commits);
174
+ out.awaySummaries.push(...l.awaySummaries);
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * Rule-based one-line description of a bucket — deterministic, no LLM.
180
+ * Evidence priority: commits (best intent signal) > away summaries (prose
181
+ * Claude Code already wrote) > prompt topics; plus an edited-files note.
182
+ */
183
+ export function describeLog(l, maxLen = 240) {
184
+ const parts = [];
185
+ if (l.commits.length) {
186
+ const shown = l.commits.slice(0, 3);
187
+ parts.push(`Commits: ${shown.join(" · ")}${l.commits.length > 3 ? ` (+${l.commits.length - 3})` : ""}`);
188
+ }
189
+ if (l.awaySummaries.length) {
190
+ parts.push(l.awaySummaries[l.awaySummaries.length - 1]);
191
+ }
192
+ else if (!l.commits.length && l.prompts.length) {
193
+ parts.push(`Topics: ${l.prompts.slice(0, 2).join(" · ")}`);
194
+ }
195
+ if (l.filesEdited.size) {
196
+ const files = [...l.filesEdited].map(shortPath);
197
+ parts.push(`${files.length} file${files.length > 1 ? "s" : ""}: ${files.slice(0, 4).join(", ")}${files.length > 4 ? " …" : ""}`);
198
+ }
199
+ if (!parts.length && l.prompts.length)
200
+ parts.push(`Topics: ${l.prompts[0]}`);
201
+ if (!parts.length && l.commands.length)
202
+ parts.push(`Commands: ${l.commands.slice(0, 2).join(" | ")}`);
203
+ const joined = parts.join(" — ");
204
+ return joined.length > maxLen ? joined.slice(0, maxLen - 1) + "…" : joined;
205
+ }
206
+ export const WORKLOG_LLM_TEMPLATE = "Below is a structured hourly worklog extracted from coding-agent session logs. " +
207
+ "For each hour, write ONE concise line (max 15 words) describing the work done, " +
208
+ "suitable as an invoice attachment. Then add a 3-sentence overall summary. " +
209
+ "Keep technical terms, skip pleasantries.";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "agent-hours",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/pmochine/agent-hours.git"
7
+ },
8
+ "homepage": "https://github.com/pmochine/agent-hours#readme",
9
+ "bugs": "https://github.com/pmochine/agent-hours/issues",
10
+ "description": "Billable hours from your local coding-agent session logs (Claude Code today) — with the three-state split no other tool gives you: hands-on / supervised / AI-autonomous.",
11
+ "type": "module",
12
+ "bin": {
13
+ "agent-hours": "dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18.11"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc && node scripts/sync-skill.mjs",
25
+ "test": "npm run build && node --test test/*.test.mjs",
26
+ "prepublishOnly": "npm run build && node --test test/*.test.mjs"
27
+ },
28
+ "keywords": [
29
+ "claude",
30
+ "claude-code",
31
+ "time-tracking",
32
+ "billing",
33
+ "freelance",
34
+ "hours",
35
+ "cli"
36
+ ],
37
+ "author": "Philipp Mochine",
38
+ "license": "MIT",
39
+ "devDependencies": {
40
+ "@types/node": "^20.14.0",
41
+ "typescript": "^5.5.0"
42
+ }
43
+ }