portable-agent-layer 0.17.0 → 0.18.1
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/assets/templates/PAL/ALGORITHM.md +30 -9
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +3 -2
- package/src/hooks/lib/paths.ts +1 -0
- package/src/targets/lib.ts +2 -1
- package/src/tools/agent/analyze.ts +157 -0
- package/src/tools/agent/wisdom-frame.ts +235 -0
- package/src/tools/export.ts +23 -17
- package/src/tools/import.ts +65 -77
- package/src/tools/relationship-reflect.ts +80 -85
- package/src/tools/session-summary.ts +44 -41
- package/src/tools/token-cost.ts +134 -92
- package/src/tools/analyze.ts +0 -152
package/src/tools/import.ts
CHANGED
|
@@ -13,53 +13,21 @@ import { createInterface } from "node:readline";
|
|
|
13
13
|
import AdmZip from "adm-zip";
|
|
14
14
|
import { palHome } from "../hooks/lib/paths";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const dryRun = args.includes("--dry-run");
|
|
19
|
-
const pathArg = args.find((a) => a !== "--dry-run");
|
|
16
|
+
export function findLatestExport(root: string): string | null {
|
|
17
|
+
const candidates: string[] = [];
|
|
20
18
|
|
|
21
|
-
async function confirm(message: string): Promise<boolean> {
|
|
22
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
23
|
-
return new Promise((res) => {
|
|
24
|
-
rl.question(`${message} [y/N] `, (answer) => {
|
|
25
|
-
rl.close();
|
|
26
|
-
res(answer.trim().toLowerCase() === "y");
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function findLatestExport(): string | null {
|
|
32
|
-
const files = readdirSync(repoRoot)
|
|
33
|
-
.filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
|
|
34
|
-
.sort()
|
|
35
|
-
.reverse();
|
|
36
|
-
|
|
37
|
-
// Also check backups/
|
|
38
19
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
(f) =>
|
|
43
|
-
|
|
44
|
-
f.endsWith(".zip")
|
|
45
|
-
)
|
|
46
|
-
.map((f) => ({ name: f, path: resolve(backupDir, f) }))
|
|
47
|
-
.sort((a, b) => b.name.localeCompare(a.name));
|
|
48
|
-
if (backups.length > 0) files.push(backups[0].name);
|
|
20
|
+
candidates.push(
|
|
21
|
+
...readdirSync(root)
|
|
22
|
+
.filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
|
|
23
|
+
.map((f) => resolve(root, f))
|
|
24
|
+
);
|
|
49
25
|
} catch {
|
|
50
|
-
|
|
26
|
+
/* empty */
|
|
51
27
|
}
|
|
52
28
|
|
|
53
|
-
if (files.length === 0) return null;
|
|
54
|
-
|
|
55
|
-
// Find the most recent by mtime across both locations
|
|
56
|
-
const candidates = [
|
|
57
|
-
...readdirSync(repoRoot)
|
|
58
|
-
.filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
|
|
59
|
-
.map((f) => resolve(repoRoot, f)),
|
|
60
|
-
];
|
|
61
29
|
try {
|
|
62
|
-
const backupDir = resolve(
|
|
30
|
+
const backupDir = resolve(root, "backups");
|
|
63
31
|
candidates.push(
|
|
64
32
|
...readdirSync(backupDir)
|
|
65
33
|
.filter(
|
|
@@ -70,54 +38,74 @@ function findLatestExport(): string | null {
|
|
|
70
38
|
.map((f) => resolve(backupDir, f))
|
|
71
39
|
);
|
|
72
40
|
} catch {
|
|
73
|
-
|
|
41
|
+
/* empty */
|
|
74
42
|
}
|
|
75
43
|
|
|
76
44
|
if (candidates.length === 0) return null;
|
|
77
45
|
return candidates.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
78
46
|
}
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
|
|
48
|
+
export function importZip(zipPath: string, targetDir: string, dryRun: boolean): number {
|
|
49
|
+
const zip = new AdmZip(zipPath);
|
|
50
|
+
const entries = zip.getEntries();
|
|
82
51
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const latest = findLatestExport();
|
|
87
|
-
if (!latest) {
|
|
88
|
-
console.error(
|
|
89
|
-
"No export or backup files found. Provide a path: bun run tool:import <path-to-zip>"
|
|
90
|
-
);
|
|
91
|
-
process.exit(1);
|
|
52
|
+
if (entries.length === 0) {
|
|
53
|
+
console.log("Archive is empty — nothing to import.");
|
|
54
|
+
return 0;
|
|
92
55
|
}
|
|
93
|
-
console.log(`Found: ${latest}`);
|
|
94
|
-
const zip = new AdmZip(latest);
|
|
95
|
-
const entries = zip.getEntries();
|
|
96
|
-
console.log(
|
|
97
|
-
`Contains ${entries.length} files, created ${statSync(latest).mtime.toISOString().slice(0, 16).replace("T", " ")}`
|
|
98
|
-
);
|
|
99
56
|
|
|
100
|
-
if (
|
|
101
|
-
console.log(
|
|
102
|
-
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
console.log(`Would import ${entries.length} files → ${targetDir}\n`);
|
|
59
|
+
for (const e of entries) console.log(` ${e.entryName}`);
|
|
60
|
+
return entries.length;
|
|
103
61
|
}
|
|
104
|
-
|
|
62
|
+
|
|
63
|
+
zip.extractAllTo(targetDir, true);
|
|
64
|
+
console.log(`Imported ${entries.length} files → ${targetDir}`);
|
|
65
|
+
console.log("\nRun 'bun run install:all' to re-create symlinks and hooks.");
|
|
66
|
+
return entries.length;
|
|
105
67
|
}
|
|
106
68
|
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
69
|
+
async function run() {
|
|
70
|
+
const repoRoot = palHome();
|
|
71
|
+
const args = process.argv.slice(2);
|
|
72
|
+
const dryRun = args.includes("--dry-run");
|
|
73
|
+
const pathArg = args.find((a) => a !== "--dry-run");
|
|
110
74
|
|
|
111
|
-
|
|
112
|
-
console.log("Archive is empty — nothing to import.");
|
|
113
|
-
process.exit(0);
|
|
114
|
-
}
|
|
75
|
+
let zipPath: string;
|
|
115
76
|
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
77
|
+
if (pathArg) {
|
|
78
|
+
zipPath = resolve(pathArg);
|
|
79
|
+
} else {
|
|
80
|
+
const latest = findLatestExport(repoRoot);
|
|
81
|
+
if (!latest) {
|
|
82
|
+
console.error(
|
|
83
|
+
"No export or backup files found. Provide a path: bun run tool:import <path-to-zip>"
|
|
84
|
+
);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
console.log(`Found: ${latest}`);
|
|
88
|
+
const zip = new AdmZip(latest);
|
|
89
|
+
const entries = zip.getEntries();
|
|
90
|
+
console.log(
|
|
91
|
+
`Contains ${entries.length} files, created ${statSync(latest).mtime.toISOString().slice(0, 16).replace("T", " ")}`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
95
|
+
const answer = await new Promise<string>((res) => {
|
|
96
|
+
rl.question("Import this file? [y/N] ", (a) => {
|
|
97
|
+
rl.close();
|
|
98
|
+
res(a);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
102
|
+
console.log("Cancelled.");
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
zipPath = latest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
importZip(zipPath, repoRoot, dryRun);
|
|
123
109
|
}
|
|
110
|
+
|
|
111
|
+
if (import.meta.main) run();
|
|
@@ -27,12 +27,6 @@ import {
|
|
|
27
27
|
import { palHome } from "../hooks/lib/paths";
|
|
28
28
|
import { similarity } from "../hooks/lib/text-similarity";
|
|
29
29
|
|
|
30
|
-
// ── Paths ──
|
|
31
|
-
|
|
32
|
-
const RATINGS_FILE = resolve(palHome(), "memory", "signals", "ratings.jsonl");
|
|
33
|
-
const RELATIONSHIP_DIR = resolve(palHome(), "memory", "relationship");
|
|
34
|
-
const REFLECTION_DIR = resolve(palHome(), "memory", "relationship", "reflections");
|
|
35
|
-
|
|
36
30
|
// ── Types ──
|
|
37
31
|
|
|
38
32
|
interface Rating {
|
|
@@ -59,16 +53,17 @@ interface OpinionChange {
|
|
|
59
53
|
|
|
60
54
|
// ── Note Parsing ──
|
|
61
55
|
|
|
62
|
-
function loadNotes(daysBack: number): ParsedNote[] {
|
|
63
|
-
|
|
56
|
+
export function loadNotes(daysBack: number): ParsedNote[] {
|
|
57
|
+
const relationshipDir = resolve(palHome(), "memory", "relationship");
|
|
58
|
+
if (!existsSync(relationshipDir)) return [];
|
|
64
59
|
|
|
65
60
|
const cutoff = new Date();
|
|
66
61
|
cutoff.setDate(cutoff.getDate() - daysBack);
|
|
67
62
|
const notes: ParsedNote[] = [];
|
|
68
63
|
|
|
69
|
-
for (const monthDir of readdirSync(
|
|
64
|
+
for (const monthDir of readdirSync(relationshipDir).sort().reverse()) {
|
|
70
65
|
if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
|
|
71
|
-
const monthPath = resolve(
|
|
66
|
+
const monthPath = resolve(relationshipDir, monthDir);
|
|
72
67
|
|
|
73
68
|
let files: string[];
|
|
74
69
|
try {
|
|
@@ -130,13 +125,14 @@ function loadNotes(daysBack: number): ParsedNote[] {
|
|
|
130
125
|
|
|
131
126
|
// ── Ratings ──
|
|
132
127
|
|
|
133
|
-
function loadRatings(daysBack: number): Rating[] {
|
|
134
|
-
|
|
128
|
+
export function loadRatings(daysBack: number): Rating[] {
|
|
129
|
+
const ratingsFile = resolve(palHome(), "memory", "signals", "ratings.jsonl");
|
|
130
|
+
if (!existsSync(ratingsFile)) return [];
|
|
135
131
|
|
|
136
132
|
const cutoff = new Date();
|
|
137
133
|
cutoff.setDate(cutoff.getDate() - daysBack);
|
|
138
134
|
|
|
139
|
-
return readFileSync(
|
|
135
|
+
return readFileSync(ratingsFile, "utf-8")
|
|
140
136
|
.split("\n")
|
|
141
137
|
.filter((l) => l.trim())
|
|
142
138
|
.map((l) => {
|
|
@@ -153,11 +149,10 @@ function loadRatings(daysBack: number): Rating[] {
|
|
|
153
149
|
|
|
154
150
|
// ── Opinion Promotion ──
|
|
155
151
|
|
|
156
|
-
function promoteToOpinions(notes: ParsedNote[], dryRun: boolean): OpinionChange[] {
|
|
152
|
+
export function promoteToOpinions(notes: ParsedNote[], dryRun: boolean): OpinionChange[] {
|
|
157
153
|
const changes: OpinionChange[] = [];
|
|
158
154
|
const opinions = readOpinions();
|
|
159
155
|
|
|
160
|
-
// All O notes in the window — deduplication happens at the evidence level
|
|
161
156
|
const opinionNotes = notes.filter((n) => n.type === "O");
|
|
162
157
|
|
|
163
158
|
// Group similar notes together
|
|
@@ -177,11 +172,9 @@ function promoteToOpinions(notes: ParsedNote[], dryRun: boolean): OpinionChange[
|
|
|
177
172
|
}
|
|
178
173
|
|
|
179
174
|
for (const [representative, group] of groups) {
|
|
180
|
-
// Check against existing opinions
|
|
181
175
|
const existing = findSimilarOpinion(representative, opinions);
|
|
182
176
|
|
|
183
177
|
if (existing) {
|
|
184
|
-
// Add supporting evidence for each new note
|
|
185
178
|
let updated = existing;
|
|
186
179
|
for (const note of group) {
|
|
187
180
|
updated = addEvidence(updated, "supporting", note.text.slice(0, 120));
|
|
@@ -197,8 +190,6 @@ function promoteToOpinions(notes: ParsedNote[], dryRun: boolean): OpinionChange[
|
|
|
197
190
|
if (!dryRun) saveOpinion(updated);
|
|
198
191
|
}
|
|
199
192
|
} else if (group.length >= 2) {
|
|
200
|
-
// New opinion — requires at least 2 occurrences
|
|
201
|
-
// Store individual note texts as evidence so re-runs deduplicate correctly
|
|
202
193
|
let opinion = createOpinion(representative, group[0].text.slice(0, 120));
|
|
203
194
|
for (const note of group.slice(1)) {
|
|
204
195
|
opinion = addEvidence(opinion, "supporting", note.text.slice(0, 120));
|
|
@@ -224,7 +215,7 @@ interface OpinionSummary {
|
|
|
224
215
|
dates: string[];
|
|
225
216
|
}
|
|
226
217
|
|
|
227
|
-
function groupNoteOccurrences(notes: ParsedNote[]): OpinionSummary[] {
|
|
218
|
+
export function groupNoteOccurrences(notes: ParsedNote[]): OpinionSummary[] {
|
|
228
219
|
const opNotes = notes.filter((n) => n.type === "O");
|
|
229
220
|
const groups = new Map<
|
|
230
221
|
string,
|
|
@@ -297,7 +288,7 @@ function correlateRatings(ratings: Rating[]): string[] {
|
|
|
297
288
|
|
|
298
289
|
// ── Report ──
|
|
299
290
|
|
|
300
|
-
function formatReport(
|
|
291
|
+
export function formatReport(
|
|
301
292
|
period: string,
|
|
302
293
|
notes: ParsedNote[],
|
|
303
294
|
ratings: Rating[],
|
|
@@ -360,8 +351,8 @@ function formatReport(
|
|
|
360
351
|
|
|
361
352
|
if (ratingInsights.length > 0) {
|
|
362
353
|
lines.push("## Rating Insights", "");
|
|
363
|
-
for (const
|
|
364
|
-
lines.push(`- ${
|
|
354
|
+
for (const insight of ratingInsights) {
|
|
355
|
+
lines.push(`- ${insight}`);
|
|
365
356
|
}
|
|
366
357
|
lines.push("");
|
|
367
358
|
}
|
|
@@ -369,13 +360,14 @@ function formatReport(
|
|
|
369
360
|
return lines.join("\n");
|
|
370
361
|
}
|
|
371
362
|
|
|
372
|
-
function writeReport(report: string, period: string): string {
|
|
373
|
-
|
|
363
|
+
export function writeReport(report: string, period: string): string {
|
|
364
|
+
const reflectionDir = resolve(palHome(), "memory", "relationship", "reflections");
|
|
365
|
+
if (!existsSync(reflectionDir)) mkdirSync(reflectionDir, { recursive: true });
|
|
374
366
|
|
|
375
367
|
const date = new Date().toISOString().slice(0, 10);
|
|
376
368
|
const slug = period.toLowerCase().replace(/\s+/g, "-");
|
|
377
369
|
const filename = `${date}_${slug}-reflection.md`;
|
|
378
|
-
const filepath = resolve(
|
|
370
|
+
const filepath = resolve(reflectionDir, filename);
|
|
379
371
|
|
|
380
372
|
writeFileSync(filepath, report, "utf-8");
|
|
381
373
|
return filepath;
|
|
@@ -383,17 +375,18 @@ function writeReport(report: string, period: string): string {
|
|
|
383
375
|
|
|
384
376
|
// ── CLI ──
|
|
385
377
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
378
|
+
function run() {
|
|
379
|
+
const { values } = parseArgs({
|
|
380
|
+
args: Bun.argv.slice(2),
|
|
381
|
+
options: {
|
|
382
|
+
month: { type: "boolean" },
|
|
383
|
+
"dry-run": { type: "boolean" },
|
|
384
|
+
help: { type: "boolean", short: "h" },
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
if (values.help) {
|
|
389
|
+
console.log(`
|
|
397
390
|
RelationshipReflect — Periodic reflection + opinion promotion
|
|
398
391
|
|
|
399
392
|
Reads recent relationship notes and ratings. Promotes recurring
|
|
@@ -408,65 +401,67 @@ Output:
|
|
|
408
401
|
- Updates memory/relationship/opinions.json (confidence tracking)
|
|
409
402
|
- Creates reflection report in memory/relationship/reflections/
|
|
410
403
|
`);
|
|
411
|
-
|
|
412
|
-
}
|
|
404
|
+
process.exit(0);
|
|
405
|
+
}
|
|
413
406
|
|
|
414
|
-
const daysBack = values.month ? 30 : 7;
|
|
415
|
-
const period = values.month ? "Monthly" : "Weekly";
|
|
416
|
-
const dryRun = values["dry-run"] ?? false;
|
|
407
|
+
const daysBack = values.month ? 30 : 7;
|
|
408
|
+
const period = values.month ? "Monthly" : "Weekly";
|
|
409
|
+
const dryRun = values["dry-run"] ?? false;
|
|
417
410
|
|
|
418
|
-
const notes = loadNotes(daysBack);
|
|
419
|
-
const ratings = loadRatings(daysBack);
|
|
411
|
+
const notes = loadNotes(daysBack);
|
|
412
|
+
const ratings = loadRatings(daysBack);
|
|
420
413
|
|
|
421
|
-
console.log(`Loaded ${notes.length} notes from last ${daysBack} days`);
|
|
422
|
-
console.log(`Loaded ${ratings.length} ratings`);
|
|
414
|
+
console.log(`Loaded ${notes.length} notes from last ${daysBack} days`);
|
|
415
|
+
console.log(`Loaded ${ratings.length} ratings`);
|
|
423
416
|
|
|
424
|
-
if (notes.length === 0 && ratings.length === 0) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
417
|
+
if (notes.length === 0 && ratings.length === 0) {
|
|
418
|
+
console.log("No data to analyze");
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
428
421
|
|
|
429
|
-
|
|
430
|
-
const opinionChanges = promoteToOpinions(notes, dryRun);
|
|
422
|
+
const opinionChanges = promoteToOpinions(notes, dryRun);
|
|
431
423
|
|
|
432
|
-
const avgRating =
|
|
433
|
-
|
|
434
|
-
console.log(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
|
|
424
|
+
const avgRating =
|
|
425
|
+
ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : 0;
|
|
426
|
+
console.log(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
|
|
435
427
|
|
|
436
|
-
const summaries = groupNoteOccurrences(notes);
|
|
437
|
-
console.log(`Observations: ${summaries.length} unique`);
|
|
428
|
+
const summaries = groupNoteOccurrences(notes);
|
|
429
|
+
console.log(`Observations: ${summaries.length} unique`);
|
|
438
430
|
|
|
439
|
-
if (opinionChanges.length > 0) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
431
|
+
if (opinionChanges.length > 0) {
|
|
432
|
+
console.log("\nOpinion changes:");
|
|
433
|
+
for (const change of opinionChanges) {
|
|
434
|
+
if (change.action === "created") {
|
|
435
|
+
console.log(
|
|
436
|
+
` + NEW (${Math.round(change.newConfidence * 100)}%) ${change.statement.slice(0, 80)}`
|
|
437
|
+
);
|
|
438
|
+
} else {
|
|
439
|
+
console.log(
|
|
440
|
+
` ~ ${Math.round(change.oldConfidence ?? 0 * 100)}% → ${Math.round(change.newConfidence * 100)}% ${change.statement.slice(0, 80)}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
450
443
|
}
|
|
444
|
+
} else {
|
|
445
|
+
console.log("\nNo opinion changes");
|
|
451
446
|
}
|
|
452
|
-
} else {
|
|
453
|
-
console.log("\nNo opinion changes");
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (dryRun) {
|
|
457
|
-
console.log("\n[DRY RUN] Would write reflection report + update opinions");
|
|
458
|
-
} else {
|
|
459
|
-
const report = formatReport(period, notes, ratings, opinionChanges);
|
|
460
|
-
const filepath = writeReport(report, period);
|
|
461
|
-
setLastReflectDate(new Date().toISOString().slice(0, 10));
|
|
462
|
-
console.log(`\nCreated reflection report: ${filepath}`);
|
|
463
447
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
448
|
+
if (dryRun) {
|
|
449
|
+
console.log("\n[DRY RUN] Would write reflection report + update opinions");
|
|
450
|
+
} else {
|
|
451
|
+
const report = formatReport(period, notes, ratings, opinionChanges);
|
|
452
|
+
const filepath = writeReport(report, period);
|
|
453
|
+
setLastReflectDate(new Date().toISOString().slice(0, 10));
|
|
454
|
+
console.log(`\nCreated reflection report: ${filepath}`);
|
|
455
|
+
|
|
456
|
+
const opinions = readOpinions();
|
|
457
|
+
const high = opinions.filter((o) => o.confidence >= 0.85);
|
|
458
|
+
if (high.length > 0) {
|
|
459
|
+
console.log("\nHigh-confidence opinions (injected into context):");
|
|
460
|
+
for (const o of high) {
|
|
461
|
+
console.log(` [${Math.round(o.confidence * 100)}%] ${o.statement.slice(0, 80)}`);
|
|
462
|
+
}
|
|
470
463
|
}
|
|
471
464
|
}
|
|
472
465
|
}
|
|
466
|
+
|
|
467
|
+
if (import.meta.main) run();
|
|
@@ -6,25 +6,30 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
9
10
|
import { resolve } from "node:path";
|
|
10
11
|
import { parseArgs } from "node:util";
|
|
11
12
|
import { MODEL_PRICING } from "../hooks/lib/models";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
options: { session: { type: "string" } },
|
|
15
|
-
strict: false,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const sessionId = args.session;
|
|
19
|
-
if (!sessionId) process.exit(0);
|
|
20
|
-
|
|
21
|
-
import { homedir } from "node:os";
|
|
14
|
+
// ── Types ──
|
|
22
15
|
|
|
23
|
-
|
|
16
|
+
interface Usage {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
cacheRead: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
calls: number;
|
|
23
|
+
models: Set<string>;
|
|
24
|
+
durationMs: number;
|
|
25
|
+
}
|
|
24
26
|
|
|
25
|
-
// ──
|
|
27
|
+
// ── Core Functions ──
|
|
26
28
|
|
|
27
|
-
function findSessionFile(
|
|
29
|
+
export function findSessionFile(
|
|
30
|
+
sessionId: string,
|
|
31
|
+
claudeDir: string
|
|
32
|
+
): { filepath: string; project: string } | null {
|
|
28
33
|
if (!existsSync(claudeDir)) return null;
|
|
29
34
|
|
|
30
35
|
const projectDirs = readdirSync(claudeDir, { withFileTypes: true }).filter((d) =>
|
|
@@ -35,7 +40,6 @@ function findSessionFile(): { filepath: string; project: string } | null {
|
|
|
35
40
|
const projPath = resolve(claudeDir, dir.name);
|
|
36
41
|
const projName = dir.name.split("-").pop() ?? dir.name;
|
|
37
42
|
|
|
38
|
-
// Session ID is often the filename
|
|
39
43
|
const directFile = resolve(projPath, `${sessionId}.jsonl`);
|
|
40
44
|
if (existsSync(directFile)) {
|
|
41
45
|
return { filepath: directFile, project: projName };
|
|
@@ -74,20 +78,7 @@ function findSessionFile(): { filepath: string; project: string } | null {
|
|
|
74
78
|
return latest;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
interface Usage {
|
|
80
|
-
input: number;
|
|
81
|
-
output: number;
|
|
82
|
-
cacheWrite: number;
|
|
83
|
-
cacheRead: number;
|
|
84
|
-
cost: number;
|
|
85
|
-
calls: number;
|
|
86
|
-
models: Set<string>;
|
|
87
|
-
durationMs: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function parseSession(filepath: string): Usage {
|
|
81
|
+
export function parseSession(filepath: string, sessionId: string): Usage {
|
|
91
82
|
const usage: Usage = {
|
|
92
83
|
input: 0,
|
|
93
84
|
output: 0,
|
|
@@ -122,7 +113,6 @@ function parseSession(filepath: string): Usage {
|
|
|
122
113
|
};
|
|
123
114
|
};
|
|
124
115
|
|
|
125
|
-
// Only count messages from this session
|
|
126
116
|
if (d.sessionId !== sessionId) continue;
|
|
127
117
|
|
|
128
118
|
if (d.timestamp) {
|
|
@@ -187,21 +177,34 @@ function fmtDuration(ms: number): string {
|
|
|
187
177
|
return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`;
|
|
188
178
|
}
|
|
189
179
|
|
|
190
|
-
// ──
|
|
180
|
+
// ── CLI ──
|
|
181
|
+
|
|
182
|
+
function run() {
|
|
183
|
+
const { values: args } = parseArgs({
|
|
184
|
+
options: { session: { type: "string" } },
|
|
185
|
+
strict: false,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const sessionId = typeof args.session === "string" ? args.session : "";
|
|
189
|
+
if (!sessionId) process.exit(0);
|
|
191
190
|
|
|
192
|
-
const
|
|
193
|
-
|
|
191
|
+
const claudeDir = resolve(homedir(), ".claude", "projects");
|
|
192
|
+
const file = findSessionFile(sessionId, claudeDir);
|
|
193
|
+
if (!file) process.exit(0);
|
|
194
194
|
|
|
195
|
-
const usage = parseSession(file.filepath);
|
|
196
|
-
if (usage.calls === 0) process.exit(0);
|
|
195
|
+
const usage = parseSession(file.filepath, sessionId);
|
|
196
|
+
if (usage.calls === 0) process.exit(0);
|
|
197
197
|
|
|
198
|
-
const totalTokens = usage.input + usage.output + usage.cacheWrite + usage.cacheRead;
|
|
199
|
-
const model = [...usage.models].map((m) => m.replace("claude-", "")).join(", ");
|
|
198
|
+
const totalTokens = usage.input + usage.output + usage.cacheWrite + usage.cacheRead;
|
|
199
|
+
const model = [...usage.models].map((m) => m.replace("claude-", "")).join(", ");
|
|
200
200
|
|
|
201
|
-
const dim = "\x1b[2m";
|
|
202
|
-
const reset = "\x1b[0m";
|
|
203
|
-
const cyan = "\x1b[36m";
|
|
201
|
+
const dim = "\x1b[2m";
|
|
202
|
+
const reset = "\x1b[0m";
|
|
203
|
+
const cyan = "\x1b[36m";
|
|
204
|
+
|
|
205
|
+
console.log(
|
|
206
|
+
`\n${dim}Session: ${file.project} · ${model} · ${fmtDuration(usage.durationMs)} · ${fmtTokens(totalTokens)} tokens · ${usage.calls} calls · ${cyan}${fmtCost(usage.cost)}${reset}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
204
209
|
|
|
205
|
-
|
|
206
|
-
`\n${dim}Session: ${file.project} · ${model} · ${fmtDuration(usage.durationMs)} · ${fmtTokens(totalTokens)} tokens · ${usage.calls} calls · ${cyan}${fmtCost(usage.cost)}${reset}`
|
|
207
|
-
);
|
|
210
|
+
if (import.meta.main) run();
|