portable-agent-layer 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 +80 -0
- package/assets/agents/claude-researcher.md +43 -0
- package/assets/agents/investigative-researcher.md +44 -0
- package/assets/agents/multi-perspective-researcher.md +43 -0
- package/assets/skills/analyze-pdf.md +40 -0
- package/assets/skills/analyze-youtube.md +35 -0
- package/assets/skills/council.md +43 -0
- package/assets/skills/create-skill.md +31 -0
- package/assets/skills/extract-entities.md +63 -0
- package/assets/skills/extract-wisdom.md +18 -0
- package/assets/skills/first-principles.md +17 -0
- package/assets/skills/fyzz-chat-api.md +43 -0
- package/assets/skills/reflect.md +87 -0
- package/assets/skills/research.md +68 -0
- package/assets/skills/review.md +19 -0
- package/assets/skills/summarize.md +15 -0
- package/assets/templates/AGENTS.md.template +45 -0
- package/assets/templates/telos/BELIEFS.md +4 -0
- package/assets/templates/telos/CHALLENGES.md +4 -0
- package/assets/templates/telos/GOALS.md +12 -0
- package/assets/templates/telos/IDEAS.md +4 -0
- package/assets/templates/telos/IDENTITY.md +4 -0
- package/assets/templates/telos/LEARNED.md +4 -0
- package/assets/templates/telos/MISSION.md +4 -0
- package/assets/templates/telos/MODELS.md +4 -0
- package/assets/templates/telos/NARRATIVES.md +4 -0
- package/assets/templates/telos/PROJECTS.md +7 -0
- package/assets/templates/telos/STRATEGIES.md +4 -0
- package/bin/pal +24 -0
- package/bin/pal.bat +8 -0
- package/bin/pal.ps1 +30 -0
- package/package.json +82 -0
- package/src/cli/index.ts +344 -0
- package/src/cli/install.ts +86 -0
- package/src/cli/uninstall.ts +45 -0
- package/src/hooks/LoadContext.ts +41 -0
- package/src/hooks/SecurityValidator.ts +52 -0
- package/src/hooks/SkillGuard.ts +41 -0
- package/src/hooks/StopOrchestrator.ts +35 -0
- package/src/hooks/UserPromptOrchestrator.ts +35 -0
- package/src/hooks/handlers/backup.ts +41 -0
- package/src/hooks/handlers/failure.ts +136 -0
- package/src/hooks/handlers/rating.ts +409 -0
- package/src/hooks/handlers/relationship.ts +113 -0
- package/src/hooks/handlers/session-name.ts +121 -0
- package/src/hooks/handlers/synthesis.ts +109 -0
- package/src/hooks/handlers/tab.ts +8 -0
- package/src/hooks/handlers/update-counts.ts +151 -0
- package/src/hooks/handlers/work-learning.ts +183 -0
- package/src/hooks/handlers/work-session.ts +58 -0
- package/src/hooks/lib/claude-md.ts +121 -0
- package/src/hooks/lib/context.ts +433 -0
- package/src/hooks/lib/entities.ts +304 -0
- package/src/hooks/lib/export.ts +76 -0
- package/src/hooks/lib/inference.ts +91 -0
- package/src/hooks/lib/learning-category.ts +14 -0
- package/src/hooks/lib/log.ts +53 -0
- package/src/hooks/lib/models.ts +16 -0
- package/src/hooks/lib/paths.ts +80 -0
- package/src/hooks/lib/relationship.ts +135 -0
- package/src/hooks/lib/security.ts +122 -0
- package/src/hooks/lib/session-names.ts +247 -0
- package/src/hooks/lib/setup.ts +189 -0
- package/src/hooks/lib/signal-trends.ts +117 -0
- package/src/hooks/lib/signals.ts +37 -0
- package/src/hooks/lib/stdin.ts +18 -0
- package/src/hooks/lib/stop.ts +155 -0
- package/src/hooks/lib/time.ts +19 -0
- package/src/hooks/lib/token-usage.ts +42 -0
- package/src/hooks/lib/transcript.ts +76 -0
- package/src/hooks/lib/wisdom.ts +48 -0
- package/src/hooks/lib/work-tracking.ts +193 -0
- package/src/hooks/setup-check.ts +42 -0
- package/src/targets/claude/install.ts +145 -0
- package/src/targets/claude/uninstall.ts +101 -0
- package/src/targets/lib.ts +337 -0
- package/src/targets/opencode/install.ts +59 -0
- package/src/targets/opencode/plugin.ts +328 -0
- package/src/targets/opencode/uninstall.ts +57 -0
- package/src/tools/entity-save.ts +110 -0
- package/src/tools/export.ts +34 -0
- package/src/tools/fyzz-api.ts +104 -0
- package/src/tools/import.ts +123 -0
- package/src/tools/pattern-synthesis.ts +435 -0
- package/src/tools/pdf-download.ts +102 -0
- package/src/tools/relationship-reflect.ts +362 -0
- package/src/tools/session-summary.ts +206 -0
- package/src/tools/token-cost.ts +301 -0
- package/src/tools/youtube-analyze.ts +105 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* RelationshipReflect — Periodic reflection on relationship patterns.
|
|
4
|
+
*
|
|
5
|
+
* Reads recent relationship notes and ratings to surface:
|
|
6
|
+
* - Opinion confidence trends (which observations keep recurring?)
|
|
7
|
+
* - Rating correlation (what interaction patterns correlate with low/high ratings?)
|
|
8
|
+
* - Summary of the relationship state
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bun run tool:reflect # Reflect on last 7 days
|
|
12
|
+
* bun run tool:reflect -- --month # Reflect on last 30 days
|
|
13
|
+
* bun run tool:reflect -- --dry-run # Preview without writing
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { palHome } from "../hooks/lib/paths";
|
|
20
|
+
|
|
21
|
+
// ── Paths ──
|
|
22
|
+
|
|
23
|
+
const RATINGS_FILE = resolve(palHome(), "memory", "signals", "ratings.jsonl");
|
|
24
|
+
const RELATIONSHIP_DIR = resolve(palHome(), "memory", "relationship");
|
|
25
|
+
const REFLECTION_DIR = resolve(palHome(), "memory", "relationship", "reflections");
|
|
26
|
+
|
|
27
|
+
// ── Types ──
|
|
28
|
+
|
|
29
|
+
interface Rating {
|
|
30
|
+
ts: string;
|
|
31
|
+
rating: number;
|
|
32
|
+
context: string;
|
|
33
|
+
source: "explicit" | "implicit";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ParsedNote {
|
|
37
|
+
type: "W" | "O";
|
|
38
|
+
text: string;
|
|
39
|
+
confidence?: number;
|
|
40
|
+
date: string;
|
|
41
|
+
time: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ReflectionResult {
|
|
45
|
+
period: string;
|
|
46
|
+
totalNotes: number;
|
|
47
|
+
totalRatings: number;
|
|
48
|
+
avgRating: number;
|
|
49
|
+
opinions: OpinionSummary[];
|
|
50
|
+
worldFacts: string[];
|
|
51
|
+
ratingCorrelation: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface OpinionSummary {
|
|
55
|
+
text: string;
|
|
56
|
+
occurrences: number;
|
|
57
|
+
avgConfidence: number;
|
|
58
|
+
dates: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Note Parsing ──
|
|
62
|
+
|
|
63
|
+
function loadNotes(daysBack: number): ParsedNote[] {
|
|
64
|
+
if (!existsSync(RELATIONSHIP_DIR)) return [];
|
|
65
|
+
|
|
66
|
+
const cutoff = new Date();
|
|
67
|
+
cutoff.setDate(cutoff.getDate() - daysBack);
|
|
68
|
+
const notes: ParsedNote[] = [];
|
|
69
|
+
|
|
70
|
+
for (const monthDir of readdirSync(RELATIONSHIP_DIR).sort().reverse()) {
|
|
71
|
+
if (monthDir === "reflections") continue;
|
|
72
|
+
const monthPath = resolve(RELATIONSHIP_DIR, monthDir);
|
|
73
|
+
|
|
74
|
+
let files: string[];
|
|
75
|
+
try {
|
|
76
|
+
files = readdirSync(monthPath)
|
|
77
|
+
.filter((f) => f.endsWith(".md"))
|
|
78
|
+
.sort()
|
|
79
|
+
.reverse();
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const dateStr = file.replace(".md", "");
|
|
86
|
+
if (new Date(dateStr) < cutoff) continue;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const content = readFileSync(resolve(monthPath, file), "utf-8");
|
|
90
|
+
let currentTime = "";
|
|
91
|
+
|
|
92
|
+
for (const line of content.split("\n")) {
|
|
93
|
+
const timeMatch = line.match(/^## (\d{2}:\d{2})/);
|
|
94
|
+
if (timeMatch) {
|
|
95
|
+
currentTime = timeMatch[1];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// O(c=0.85): text
|
|
100
|
+
const opinionMatch = line.match(/^- O\(c=([\d.]+)\):\s*(.+)$/);
|
|
101
|
+
if (opinionMatch) {
|
|
102
|
+
notes.push({
|
|
103
|
+
type: "O",
|
|
104
|
+
confidence: Number.parseFloat(opinionMatch[1]),
|
|
105
|
+
text: opinionMatch[2],
|
|
106
|
+
date: dateStr,
|
|
107
|
+
time: currentTime,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// W: text
|
|
113
|
+
const worldMatch = line.match(/^- W:\s*(.+)$/);
|
|
114
|
+
if (worldMatch) {
|
|
115
|
+
notes.push({
|
|
116
|
+
type: "W",
|
|
117
|
+
text: worldMatch[1],
|
|
118
|
+
date: dateStr,
|
|
119
|
+
time: currentTime,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// skip
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return notes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Ratings ──
|
|
133
|
+
|
|
134
|
+
function loadRatings(daysBack: number): Rating[] {
|
|
135
|
+
if (!existsSync(RATINGS_FILE)) return [];
|
|
136
|
+
|
|
137
|
+
const cutoff = new Date();
|
|
138
|
+
cutoff.setDate(cutoff.getDate() - daysBack);
|
|
139
|
+
|
|
140
|
+
return readFileSync(RATINGS_FILE, "utf-8")
|
|
141
|
+
.split("\n")
|
|
142
|
+
.filter((l) => l.trim())
|
|
143
|
+
.map((l) => {
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(l) as Rating;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
.filter(
|
|
151
|
+
(r): r is Rating => r !== null && new Date(r.ts).getTime() >= cutoff.getTime()
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Analysis ──
|
|
156
|
+
|
|
157
|
+
function groupOpinions(notes: ParsedNote[]): OpinionSummary[] {
|
|
158
|
+
const opinions = notes.filter((n) => n.type === "O");
|
|
159
|
+
const groups = new Map<string, { confidences: number[]; dates: string[] }>();
|
|
160
|
+
|
|
161
|
+
for (const op of opinions) {
|
|
162
|
+
// Normalize text for grouping (lowercase, trim)
|
|
163
|
+
const key = op.text.toLowerCase().slice(0, 100);
|
|
164
|
+
const existing = groups.get(key) ?? { confidences: [], dates: [] };
|
|
165
|
+
if (op.confidence !== undefined) existing.confidences.push(op.confidence);
|
|
166
|
+
existing.dates.push(op.date);
|
|
167
|
+
groups.set(key, existing);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const summaries: OpinionSummary[] = [];
|
|
171
|
+
for (const [, data] of groups) {
|
|
172
|
+
const originalNote = opinions.find(
|
|
173
|
+
(n) => n.text.toLowerCase().slice(0, 100) === [...groups.keys()][summaries.length]
|
|
174
|
+
);
|
|
175
|
+
const avgConf =
|
|
176
|
+
data.confidences.length > 0
|
|
177
|
+
? data.confidences.reduce((a, b) => a + b, 0) / data.confidences.length
|
|
178
|
+
: 0;
|
|
179
|
+
summaries.push({
|
|
180
|
+
text: originalNote?.text ?? "",
|
|
181
|
+
occurrences: data.dates.length,
|
|
182
|
+
avgConfidence: avgConf,
|
|
183
|
+
dates: [...new Set(data.dates)],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return summaries.sort((a, b) => b.occurrences - a.occurrences);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function correlateRatings(ratings: Rating[]): string[] {
|
|
191
|
+
const correlations: string[] = [];
|
|
192
|
+
|
|
193
|
+
const lowRatings = ratings.filter((r) => r.rating <= 4);
|
|
194
|
+
const highRatings = ratings.filter((r) => r.rating >= 7);
|
|
195
|
+
|
|
196
|
+
if (lowRatings.length > 0) {
|
|
197
|
+
correlations.push(
|
|
198
|
+
`${lowRatings.length} low ratings (<=4) — common contexts: ${lowRatings
|
|
199
|
+
.slice(0, 3)
|
|
200
|
+
.map((r) => `"${r.context.slice(0, 60)}"`)
|
|
201
|
+
.join(", ")}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (highRatings.length > 0) {
|
|
205
|
+
correlations.push(
|
|
206
|
+
`${highRatings.length} high ratings (>=7) — common contexts: ${highRatings
|
|
207
|
+
.slice(0, 3)
|
|
208
|
+
.map((r) => `"${r.context.slice(0, 60)}"`)
|
|
209
|
+
.join(", ")}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (ratings.length > 0) {
|
|
214
|
+
const explicitCount = ratings.filter((r) => r.source === "explicit").length;
|
|
215
|
+
const implicitCount = ratings.filter((r) => r.source === "implicit").length;
|
|
216
|
+
correlations.push(`Source mix: ${explicitCount} explicit, ${implicitCount} implicit`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return correlations;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function analyze(
|
|
223
|
+
notes: ParsedNote[],
|
|
224
|
+
ratings: Rating[],
|
|
225
|
+
period: string
|
|
226
|
+
): ReflectionResult {
|
|
227
|
+
const avgRating =
|
|
228
|
+
ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : 0;
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
period,
|
|
232
|
+
totalNotes: notes.length,
|
|
233
|
+
totalRatings: ratings.length,
|
|
234
|
+
avgRating,
|
|
235
|
+
opinions: groupOpinions(notes),
|
|
236
|
+
worldFacts: notes
|
|
237
|
+
.filter((n) => n.type === "W")
|
|
238
|
+
.map((n) => n.text)
|
|
239
|
+
.slice(0, 10),
|
|
240
|
+
ratingCorrelation: correlateRatings(ratings),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Report ──
|
|
245
|
+
|
|
246
|
+
function formatReport(result: ReflectionResult): string {
|
|
247
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
248
|
+
const lines: string[] = [
|
|
249
|
+
"# Relationship Reflection",
|
|
250
|
+
"",
|
|
251
|
+
`**Period:** ${result.period}`,
|
|
252
|
+
`**Generated:** ${date}`,
|
|
253
|
+
`**Notes analyzed:** ${result.totalNotes}`,
|
|
254
|
+
`**Ratings analyzed:** ${result.totalRatings}`,
|
|
255
|
+
`**Average Rating:** ${result.avgRating.toFixed(1)}/10`,
|
|
256
|
+
"",
|
|
257
|
+
"---",
|
|
258
|
+
"",
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
if (result.opinions.length > 0) {
|
|
262
|
+
lines.push("## Recurring Opinions", "");
|
|
263
|
+
for (const op of result.opinions) {
|
|
264
|
+
lines.push(
|
|
265
|
+
`- **${op.text}**`,
|
|
266
|
+
` Seen ${op.occurrences}x | Avg confidence: ${op.avgConfidence.toFixed(2)} | Dates: ${op.dates.join(", ")}`,
|
|
267
|
+
""
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (result.worldFacts.length > 0) {
|
|
273
|
+
lines.push("## World Facts Observed", "");
|
|
274
|
+
for (const fact of result.worldFacts) {
|
|
275
|
+
lines.push(`- ${fact}`);
|
|
276
|
+
}
|
|
277
|
+
lines.push("");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (result.ratingCorrelation.length > 0) {
|
|
281
|
+
lines.push("## Rating Insights", "");
|
|
282
|
+
for (const c of result.ratingCorrelation) {
|
|
283
|
+
lines.push(`- ${c}`);
|
|
284
|
+
}
|
|
285
|
+
lines.push("");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function writeReport(result: ReflectionResult, period: string): string {
|
|
292
|
+
if (!existsSync(REFLECTION_DIR)) mkdirSync(REFLECTION_DIR, { recursive: true });
|
|
293
|
+
|
|
294
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
295
|
+
const slug = period.toLowerCase().replace(/\s+/g, "-");
|
|
296
|
+
const filename = `${date}_${slug}-reflection.md`;
|
|
297
|
+
const filepath = resolve(REFLECTION_DIR, filename);
|
|
298
|
+
|
|
299
|
+
writeFileSync(filepath, formatReport(result), "utf-8");
|
|
300
|
+
return filepath;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── CLI ──
|
|
304
|
+
|
|
305
|
+
const { values } = parseArgs({
|
|
306
|
+
args: Bun.argv.slice(2),
|
|
307
|
+
options: {
|
|
308
|
+
month: { type: "boolean" },
|
|
309
|
+
"dry-run": { type: "boolean" },
|
|
310
|
+
help: { type: "boolean", short: "h" },
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (values.help) {
|
|
315
|
+
console.log(`
|
|
316
|
+
RelationshipReflect — Periodic reflection on relationship patterns
|
|
317
|
+
|
|
318
|
+
Usage:
|
|
319
|
+
bun run tool:reflect Reflect on last 7 days (default)
|
|
320
|
+
bun run tool:reflect -- --month Reflect on last 30 days
|
|
321
|
+
bun run tool:reflect -- --dry-run Preview without writing
|
|
322
|
+
|
|
323
|
+
Output: Creates reflection report in memory/relationship/reflections/
|
|
324
|
+
`);
|
|
325
|
+
process.exit(0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const daysBack = values.month ? 30 : 7;
|
|
329
|
+
const period = values.month ? "Monthly" : "Weekly";
|
|
330
|
+
|
|
331
|
+
const notes = loadNotes(daysBack);
|
|
332
|
+
const ratings = loadRatings(daysBack);
|
|
333
|
+
|
|
334
|
+
console.log(`Loaded ${notes.length} relationship notes from last ${daysBack} days`);
|
|
335
|
+
console.log(`Loaded ${ratings.length} ratings from last ${daysBack} days`);
|
|
336
|
+
|
|
337
|
+
if (notes.length === 0 && ratings.length === 0) {
|
|
338
|
+
console.log("No data to analyze");
|
|
339
|
+
process.exit(0);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = analyze(notes, ratings, period);
|
|
343
|
+
|
|
344
|
+
console.log(`\nAverage Rating: ${result.avgRating.toFixed(1)}/10`);
|
|
345
|
+
console.log(`Opinions tracked: ${result.opinions.length}`);
|
|
346
|
+
console.log(`World facts: ${result.worldFacts.length}`);
|
|
347
|
+
|
|
348
|
+
if (result.opinions.length > 0) {
|
|
349
|
+
console.log("\nTop recurring opinions:");
|
|
350
|
+
for (const op of result.opinions.slice(0, 5)) {
|
|
351
|
+
console.log(
|
|
352
|
+
` - [${op.occurrences}x, c=${op.avgConfidence.toFixed(2)}] ${op.text.slice(0, 80)}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (values["dry-run"]) {
|
|
358
|
+
console.log("\n[DRY RUN] Would write reflection report");
|
|
359
|
+
} else {
|
|
360
|
+
const filepath = writeReport(result, period);
|
|
361
|
+
console.log(`\nCreated reflection report: ${filepath}`);
|
|
362
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI tool: print a brief session summary after Claude Code exits.
|
|
3
|
+
* Designed to be called from the `pal` wrapper script.
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun run tools/session-summary.ts --session <sessionId>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { MODEL_PRICING } from "../hooks/lib/models";
|
|
12
|
+
|
|
13
|
+
const { values: args } = parseArgs({
|
|
14
|
+
options: { session: { type: "string" } },
|
|
15
|
+
strict: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sessionId = args.session;
|
|
19
|
+
if (!sessionId) process.exit(0);
|
|
20
|
+
|
|
21
|
+
const HOME = process.env.HOME ?? "~";
|
|
22
|
+
const claudeDir = resolve(HOME, ".claude", "projects");
|
|
23
|
+
|
|
24
|
+
// ── Find the JSONL file containing this session ──
|
|
25
|
+
|
|
26
|
+
function findSessionFile(): { filepath: string; project: string } | null {
|
|
27
|
+
if (!existsSync(claudeDir)) return null;
|
|
28
|
+
|
|
29
|
+
const projectDirs = readdirSync(claudeDir, { withFileTypes: true }).filter((d) =>
|
|
30
|
+
d.isDirectory()
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
for (const dir of projectDirs) {
|
|
34
|
+
const projPath = resolve(claudeDir, dir.name);
|
|
35
|
+
const projName = dir.name.split("-").pop() ?? dir.name;
|
|
36
|
+
|
|
37
|
+
// Session ID is often the filename
|
|
38
|
+
const directFile = resolve(projPath, `${sessionId}.jsonl`);
|
|
39
|
+
if (existsSync(directFile)) {
|
|
40
|
+
return { filepath: directFile, project: projName };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fallback: scan most recently modified files
|
|
45
|
+
let latest: { filepath: string; project: string; mtime: number } | null = null;
|
|
46
|
+
|
|
47
|
+
for (const dir of readdirSync(claudeDir, { withFileTypes: true }).filter((d) =>
|
|
48
|
+
d.isDirectory()
|
|
49
|
+
)) {
|
|
50
|
+
const projPath = resolve(claudeDir, dir.name);
|
|
51
|
+
const projName = dir.name.split("-").pop() ?? dir.name;
|
|
52
|
+
|
|
53
|
+
let files: string[];
|
|
54
|
+
try {
|
|
55
|
+
files = readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const filepath = resolve(projPath, file);
|
|
62
|
+
try {
|
|
63
|
+
const mtime = Bun.file(filepath).lastModified;
|
|
64
|
+
if (!latest || mtime > latest.mtime) {
|
|
65
|
+
latest = { filepath, project: projName, mtime };
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
/* skip */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return latest;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Parse only messages belonging to this session ──
|
|
77
|
+
|
|
78
|
+
interface Usage {
|
|
79
|
+
input: number;
|
|
80
|
+
output: number;
|
|
81
|
+
cacheWrite: number;
|
|
82
|
+
cacheRead: number;
|
|
83
|
+
cost: number;
|
|
84
|
+
calls: number;
|
|
85
|
+
models: Set<string>;
|
|
86
|
+
durationMs: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseSession(filepath: string): Usage {
|
|
90
|
+
const usage: Usage = {
|
|
91
|
+
input: 0,
|
|
92
|
+
output: 0,
|
|
93
|
+
cacheWrite: 0,
|
|
94
|
+
cacheRead: 0,
|
|
95
|
+
cost: 0,
|
|
96
|
+
calls: 0,
|
|
97
|
+
models: new Set(),
|
|
98
|
+
durationMs: 0,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const content = readFileSync(filepath, "utf-8");
|
|
102
|
+
let firstTs = "";
|
|
103
|
+
let lastTs = "";
|
|
104
|
+
|
|
105
|
+
for (const line of content.split("\n")) {
|
|
106
|
+
if (!line) continue;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const d = JSON.parse(line) as {
|
|
110
|
+
type?: string;
|
|
111
|
+
timestamp?: string;
|
|
112
|
+
sessionId?: string;
|
|
113
|
+
message?: {
|
|
114
|
+
model?: string;
|
|
115
|
+
usage?: {
|
|
116
|
+
input_tokens?: number;
|
|
117
|
+
output_tokens?: number;
|
|
118
|
+
cache_creation_input_tokens?: number;
|
|
119
|
+
cache_read_input_tokens?: number;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Only count messages from this session
|
|
125
|
+
if (d.sessionId !== sessionId) continue;
|
|
126
|
+
|
|
127
|
+
if (d.timestamp) {
|
|
128
|
+
if (!firstTs) firstTs = d.timestamp;
|
|
129
|
+
lastTs = d.timestamp;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (d.type !== "assistant") continue;
|
|
133
|
+
const u = d.message?.usage;
|
|
134
|
+
const model = d.message?.model;
|
|
135
|
+
if (!u || !model) continue;
|
|
136
|
+
|
|
137
|
+
const input = u.input_tokens ?? 0;
|
|
138
|
+
const output = u.output_tokens ?? 0;
|
|
139
|
+
const cw = u.cache_creation_input_tokens ?? 0;
|
|
140
|
+
const cr = u.cache_read_input_tokens ?? 0;
|
|
141
|
+
|
|
142
|
+
const p = MODEL_PRICING[model];
|
|
143
|
+
if (p) {
|
|
144
|
+
usage.cost +=
|
|
145
|
+
(input * p.input + output * p.output + cw * p.cacheWrite + cr * p.cacheRead) /
|
|
146
|
+
1_000_000;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
usage.input += input;
|
|
150
|
+
usage.output += output;
|
|
151
|
+
usage.cacheWrite += cw;
|
|
152
|
+
usage.cacheRead += cr;
|
|
153
|
+
usage.calls++;
|
|
154
|
+
usage.models.add(model);
|
|
155
|
+
} catch {
|
|
156
|
+
/* skip */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (firstTs && lastTs) {
|
|
161
|
+
usage.durationMs = new Date(lastTs).getTime() - new Date(firstTs).getTime();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return usage;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Format helpers ──
|
|
168
|
+
|
|
169
|
+
function fmtTokens(n: number): string {
|
|
170
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
171
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
172
|
+
return String(n);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fmtCost(n: number): string {
|
|
176
|
+
if (n >= 1) return `$${n.toFixed(2)}`;
|
|
177
|
+
return `$${n.toFixed(4)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function fmtDuration(ms: number): string {
|
|
181
|
+
const mins = Math.floor(ms / 60_000);
|
|
182
|
+
if (mins < 1) return "<1m";
|
|
183
|
+
if (mins < 60) return `${mins}m`;
|
|
184
|
+
const hrs = Math.floor(mins / 60);
|
|
185
|
+
const rem = mins % 60;
|
|
186
|
+
return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Main ──
|
|
190
|
+
|
|
191
|
+
const file = findSessionFile();
|
|
192
|
+
if (!file) process.exit(0);
|
|
193
|
+
|
|
194
|
+
const usage = parseSession(file.filepath);
|
|
195
|
+
if (usage.calls === 0) process.exit(0);
|
|
196
|
+
|
|
197
|
+
const totalTokens = usage.input + usage.output + usage.cacheWrite + usage.cacheRead;
|
|
198
|
+
const model = [...usage.models].map((m) => m.replace("claude-", "")).join(", ");
|
|
199
|
+
|
|
200
|
+
const dim = "\x1b[2m";
|
|
201
|
+
const reset = "\x1b[0m";
|
|
202
|
+
const cyan = "\x1b[36m";
|
|
203
|
+
|
|
204
|
+
console.log(
|
|
205
|
+
`\n${dim}Session: ${file.project} · ${model} · ${fmtDuration(usage.durationMs)} · ${fmtTokens(totalTokens)} tokens · ${usage.calls} calls · ${cyan}${fmtCost(usage.cost)}${reset}`
|
|
206
|
+
);
|