@promptctl/cc-candybar 1.0.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 +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { existsSync, createReadStream } from "node:fs";
|
|
2
|
+
// [LAW:single-enforcer] readdir/readFile/stat come from the gated transcript-fs
|
|
3
|
+
// owner, not node:fs/promises — the in-flight-I/O bound lives at one seam.
|
|
4
|
+
import { readdir, readFile, stat } from "./transcript-fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { debug } from "./logger";
|
|
9
|
+
|
|
10
|
+
export interface ClaudeHookData {
|
|
11
|
+
// cc-candybar internal — not part of Anthropic's schema
|
|
12
|
+
hook_event_name: string;
|
|
13
|
+
|
|
14
|
+
// Always present per Anthropic schema
|
|
15
|
+
session_id: string;
|
|
16
|
+
transcript_path: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
model: {
|
|
19
|
+
id: string;
|
|
20
|
+
display_name: string;
|
|
21
|
+
};
|
|
22
|
+
workspace: {
|
|
23
|
+
current_dir: string;
|
|
24
|
+
project_dir: string;
|
|
25
|
+
// "Empty array if none have been added" — always present, not absent
|
|
26
|
+
added_dirs: string[];
|
|
27
|
+
// Absent when not inside a linked git worktree
|
|
28
|
+
git_worktree?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Optional per Anthropic schema (listed under "Fields that may be absent")
|
|
32
|
+
session_name?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
output_style?: {
|
|
35
|
+
name: string;
|
|
36
|
+
};
|
|
37
|
+
cost?: {
|
|
38
|
+
total_cost_usd: number;
|
|
39
|
+
total_duration_ms: number;
|
|
40
|
+
total_api_duration_ms: number;
|
|
41
|
+
total_lines_added: number;
|
|
42
|
+
total_lines_removed: number;
|
|
43
|
+
};
|
|
44
|
+
context_window?: {
|
|
45
|
+
total_input_tokens: number;
|
|
46
|
+
total_output_tokens: number;
|
|
47
|
+
context_window_size: number;
|
|
48
|
+
// Always present within context_window, but value may be null (schema: "Fields that may be null")
|
|
49
|
+
used_percentage: number | null;
|
|
50
|
+
remaining_percentage: number | null;
|
|
51
|
+
// Null before first API call and after /compact — present, not absent
|
|
52
|
+
current_usage: {
|
|
53
|
+
input_tokens: number;
|
|
54
|
+
output_tokens: number;
|
|
55
|
+
cache_creation_input_tokens: number;
|
|
56
|
+
cache_read_input_tokens: number;
|
|
57
|
+
} | null;
|
|
58
|
+
};
|
|
59
|
+
exceeds_200k_tokens?: boolean;
|
|
60
|
+
effort?: {
|
|
61
|
+
level: string;
|
|
62
|
+
};
|
|
63
|
+
thinking?: {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
};
|
|
66
|
+
rate_limits?: {
|
|
67
|
+
five_hour?: {
|
|
68
|
+
used_percentage: number;
|
|
69
|
+
resets_at: number;
|
|
70
|
+
};
|
|
71
|
+
seven_day?: {
|
|
72
|
+
used_percentage: number;
|
|
73
|
+
resets_at: number;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
vim?: {
|
|
77
|
+
mode: string;
|
|
78
|
+
};
|
|
79
|
+
agent?: {
|
|
80
|
+
name: string;
|
|
81
|
+
};
|
|
82
|
+
worktree?: {
|
|
83
|
+
name: string;
|
|
84
|
+
path: string;
|
|
85
|
+
branch?: string;
|
|
86
|
+
original_cwd: string;
|
|
87
|
+
original_branch?: string;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getClaudePaths(): string[] {
|
|
92
|
+
const paths: string[] = [];
|
|
93
|
+
|
|
94
|
+
const envPath = process.env.CLAUDE_CONFIG_DIR;
|
|
95
|
+
if (envPath) {
|
|
96
|
+
envPath.split(",").forEach((path) => {
|
|
97
|
+
const trimmedPath = path.trim();
|
|
98
|
+
if (existsSync(trimmedPath)) {
|
|
99
|
+
paths.push(trimmedPath);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (paths.length === 0) {
|
|
105
|
+
const homeDir = homedir();
|
|
106
|
+
const configPath = join(homeDir, ".config", "claude");
|
|
107
|
+
const claudePath = join(homeDir, ".claude");
|
|
108
|
+
|
|
109
|
+
if (existsSync(configPath)) {
|
|
110
|
+
paths.push(configPath);
|
|
111
|
+
}
|
|
112
|
+
if (existsSync(claudePath)) {
|
|
113
|
+
paths.push(claudePath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return paths;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function findProjectPaths(
|
|
121
|
+
claudePaths: string[],
|
|
122
|
+
): Promise<string[]> {
|
|
123
|
+
const projectPaths: string[] = [];
|
|
124
|
+
|
|
125
|
+
for (const claudePath of claudePaths) {
|
|
126
|
+
const projectsDir = join(claudePath, "projects");
|
|
127
|
+
|
|
128
|
+
if (existsSync(projectsDir)) {
|
|
129
|
+
try {
|
|
130
|
+
const entries = await readdir(projectsDir, { withFileTypes: true });
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
const projectPath = join(projectsDir, entry.name);
|
|
135
|
+
projectPaths.push(projectPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
debug(`Failed to read projects directory ${projectsDir}:`, error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return projectPaths;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function findAgentTranscripts(
|
|
148
|
+
sessionId: string,
|
|
149
|
+
projectPath: string,
|
|
150
|
+
): Promise<string[]> {
|
|
151
|
+
const agentFiles: string[] = [];
|
|
152
|
+
|
|
153
|
+
const subagentsDir = join(projectPath, sessionId, "subagents");
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const files = await readdir(subagentsDir);
|
|
157
|
+
const agentFileNames = files.filter(
|
|
158
|
+
(f) => f.startsWith("agent-") && f.endsWith(".jsonl"),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
for (const fileName of agentFileNames) {
|
|
162
|
+
const filePath = join(subagentsDir, fileName);
|
|
163
|
+
try {
|
|
164
|
+
const content = await readFile(filePath, "utf-8");
|
|
165
|
+
const firstLine = content.split("\n")[0];
|
|
166
|
+
if (firstLine) {
|
|
167
|
+
const parsed = JSON.parse(firstLine);
|
|
168
|
+
if (parsed.sessionId === sessionId) {
|
|
169
|
+
agentFiles.push(filePath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
debug(`Failed to check agent file ${filePath}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
debug(`Failed to read subagents directory ${subagentsDir}:`, error);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return agentFiles;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function getEarliestTimestamp(
|
|
184
|
+
filePath: string,
|
|
185
|
+
): Promise<Date | null> {
|
|
186
|
+
try {
|
|
187
|
+
const content = await readFile(filePath, "utf-8");
|
|
188
|
+
const lines = content.trim().split("\n");
|
|
189
|
+
|
|
190
|
+
let earliestDate: Date | null = null;
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (!line.trim()) continue;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const json = JSON.parse(line);
|
|
196
|
+
if (json.timestamp && typeof json.timestamp === "string") {
|
|
197
|
+
const date = new Date(json.timestamp);
|
|
198
|
+
if (!isNaN(date.getTime())) {
|
|
199
|
+
if (earliestDate === null || date < earliestDate) {
|
|
200
|
+
earliestDate = date;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return earliestDate;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
debug(`Failed to get earliest timestamp for ${filePath}:`, error);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function sortFilesByTimestamp(
|
|
216
|
+
files: string[],
|
|
217
|
+
oldestFirst = true,
|
|
218
|
+
): Promise<string[]> {
|
|
219
|
+
const filesWithTimestamps = await Promise.all(
|
|
220
|
+
files.map(async (file) => ({
|
|
221
|
+
file,
|
|
222
|
+
timestamp: await getEarliestTimestamp(file),
|
|
223
|
+
})),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return filesWithTimestamps
|
|
227
|
+
.sort((a, b) => {
|
|
228
|
+
if (a.timestamp === null && b.timestamp === null) return 0;
|
|
229
|
+
if (a.timestamp === null) return 1;
|
|
230
|
+
if (b.timestamp === null) return -1;
|
|
231
|
+
const sortOrder = oldestFirst ? 1 : -1;
|
|
232
|
+
return sortOrder * (a.timestamp.getTime() - b.timestamp.getTime());
|
|
233
|
+
})
|
|
234
|
+
.map((item) => item.file);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function getFileModificationDate(
|
|
238
|
+
filePath: string,
|
|
239
|
+
): Promise<Date | null> {
|
|
240
|
+
try {
|
|
241
|
+
const stats = await stat(filePath);
|
|
242
|
+
return stats.mtime;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// [LAW:types-are-the-program] exception: kept as type-aliases (not
|
|
249
|
+
// interfaces) so they're structurally assignable to `Record<string,
|
|
250
|
+
// unknown>` at the `extractModelId` boundary in segments/pricing.ts.
|
|
251
|
+
// Switching to `interface` removes the implicit index signature and
|
|
252
|
+
// breaks typecheck. A proper fix tightens that boundary to take
|
|
253
|
+
// `PrunedRaw` directly and is out of scope for the CI-fix branch.
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
255
|
+
type UsageCounts = {
|
|
256
|
+
input_tokens?: number;
|
|
257
|
+
output_tokens?: number;
|
|
258
|
+
cache_creation_input_tokens?: number;
|
|
259
|
+
cache_read_input_tokens?: number;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// [LAW:one-source-of-truth] The only fields ever read from raw are
|
|
263
|
+
// model, message.{id,model,usage}, and requestId. Storing the full
|
|
264
|
+
// parsed JSON (including content arrays with full message text) causes
|
|
265
|
+
// hundreds of MB of V8 heap churn per transcript re-parse. raw is
|
|
266
|
+
// pruned to this shape at parse time so the GC pressure is bounded.
|
|
267
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
268
|
+
export type PrunedRaw = {
|
|
269
|
+
model?: string;
|
|
270
|
+
message?: { id?: string; model?: string; usage?: UsageCounts };
|
|
271
|
+
requestId?: string;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export interface ParsedEntry {
|
|
275
|
+
timestamp: Date;
|
|
276
|
+
// [LAW:one-source-of-truth] The pruned projection carries every scalar any
|
|
277
|
+
// consumer reads. `type`, `message.role/type/firstContentType` are the
|
|
278
|
+
// message-classification discriminators the metrics segment needs; they are
|
|
279
|
+
// small enum-like strings, so projecting them keeps metrics on this one parse
|
|
280
|
+
// path WITHOUT retaining the multi-MB `message.content[]` arrays the pruning
|
|
281
|
+
// exists to drop. `firstContentType` is the `type` of the first content block
|
|
282
|
+
// only (undefined when content is text/absent) — never the array itself.
|
|
283
|
+
type?: string;
|
|
284
|
+
message?: {
|
|
285
|
+
id?: string;
|
|
286
|
+
usage?: UsageCounts;
|
|
287
|
+
model?: string;
|
|
288
|
+
role?: string;
|
|
289
|
+
type?: string;
|
|
290
|
+
firstContentType?: string;
|
|
291
|
+
};
|
|
292
|
+
costUSD?: number;
|
|
293
|
+
isSidechain?: boolean;
|
|
294
|
+
raw: PrunedRaw;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function createUniqueHash(entry: ParsedEntry): string | null {
|
|
298
|
+
// Both message.id paths are now equivalent (makeEntry syncs them), but
|
|
299
|
+
// raw.message.id is kept as the canonical source to preserve call-site
|
|
300
|
+
// compatibility with callers that pass a PrunedRaw directly.
|
|
301
|
+
const messageId = entry.message?.id ?? entry.raw.message?.id;
|
|
302
|
+
const requestId = entry.raw.requestId;
|
|
303
|
+
|
|
304
|
+
if (!messageId || !requestId) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `${messageId}:${requestId}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const STREAMING_THRESHOLD_BYTES = 1024 * 1024;
|
|
312
|
+
|
|
313
|
+
// [LAW:no-shared-mutable-globals] Bounded LRU — single owner, hard cap, documented invariants.
|
|
314
|
+
// Key: filePath. Value: last-seen mtime+size for freshness check plus parsed entries.
|
|
315
|
+
// Files larger than PARSE_CACHE_SKIP_BYTES are streamed and not retained (too expensive).
|
|
316
|
+
const PARSE_CACHE_MAX = 16;
|
|
317
|
+
const PARSE_CACHE_SKIP_BYTES = 5 * 1024 * 1024;
|
|
318
|
+
|
|
319
|
+
interface ParseCacheEntry {
|
|
320
|
+
mtime: number;
|
|
321
|
+
size: number;
|
|
322
|
+
entries: ParsedEntry[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const parseCache = new Map<string, ParseCacheEntry>();
|
|
326
|
+
|
|
327
|
+
export function clearParseCache(): void {
|
|
328
|
+
parseCache.clear();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function parseJsonlFile(filePath: string): Promise<ParsedEntry[]> {
|
|
332
|
+
try {
|
|
333
|
+
const stats = await stat(filePath);
|
|
334
|
+
const fileSizeBytes = stats.size;
|
|
335
|
+
|
|
336
|
+
const cached = parseCache.get(filePath);
|
|
337
|
+
if (
|
|
338
|
+
cached &&
|
|
339
|
+
cached.mtime === stats.mtimeMs &&
|
|
340
|
+
cached.size === fileSizeBytes
|
|
341
|
+
) {
|
|
342
|
+
debug(`[parse-cache] hit ${filePath}`);
|
|
343
|
+
// LRU: move to most-recently-used end via delete+reinsert
|
|
344
|
+
parseCache.delete(filePath);
|
|
345
|
+
parseCache.set(filePath, cached);
|
|
346
|
+
return cached.entries;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let entries: ParsedEntry[];
|
|
350
|
+
if (fileSizeBytes > STREAMING_THRESHOLD_BYTES) {
|
|
351
|
+
debug(
|
|
352
|
+
`Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`,
|
|
353
|
+
);
|
|
354
|
+
entries = await parseJsonlFileStreaming(filePath);
|
|
355
|
+
} else {
|
|
356
|
+
entries = await parseJsonlFileInMemory(filePath);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
debug(`Parsed ${entries.length} entries from ${filePath}`);
|
|
360
|
+
|
|
361
|
+
// Large files are already streamed — retaining parsed entries pins memory. Skip cache.
|
|
362
|
+
if (fileSizeBytes > PARSE_CACHE_SKIP_BYTES) {
|
|
363
|
+
return entries;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Evict stale entry for this path (mtime changed) before measuring capacity.
|
|
367
|
+
parseCache.delete(filePath);
|
|
368
|
+
// Evict LRU entry if at cap.
|
|
369
|
+
if (parseCache.size >= PARSE_CACHE_MAX) {
|
|
370
|
+
parseCache.delete(parseCache.keys().next().value!);
|
|
371
|
+
}
|
|
372
|
+
parseCache.set(filePath, {
|
|
373
|
+
mtime: stats.mtimeMs,
|
|
374
|
+
size: fileSizeBytes,
|
|
375
|
+
entries,
|
|
376
|
+
});
|
|
377
|
+
return entries;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// [LAW:no-silent-failure] A transcript that doesn't exist yet is the
|
|
380
|
+
// domain's genuine "no entries" (new session pre-first-write) — every
|
|
381
|
+
// other read error propagates so the consuming provider classifies it
|
|
382
|
+
// as a failed outcome and the payload boundary logs it. The old
|
|
383
|
+
// catch-all-to-[] dressed EACCES/EIO as an empty session.
|
|
384
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
385
|
+
debug(`Transcript not present yet: ${filePath}`);
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Build a ParsedEntry from a full parsed JSONL line, retaining only the
|
|
393
|
+
// fields actually used downstream. The full parsed object (which includes
|
|
394
|
+
// message.content arrays with complete LLM response text) is discarded so
|
|
395
|
+
// the GC can reclaim that memory promptly instead of pinning it via raw.
|
|
396
|
+
// [LAW:one-source-of-truth] All callers go through here; no second parse path.
|
|
397
|
+
function makeEntry(parsed: Record<string, unknown>): ParsedEntry | null {
|
|
398
|
+
if (!parsed.timestamp) return null;
|
|
399
|
+
const msg = parsed.message as Record<string, unknown> | undefined;
|
|
400
|
+
const usage = msg?.usage as UsageCounts | undefined;
|
|
401
|
+
// Project only the first content block's `type` scalar; the array (full LLM
|
|
402
|
+
// text) is never retained. Text content (a bare string) has no block type.
|
|
403
|
+
const content = msg?.content;
|
|
404
|
+
const firstBlock =
|
|
405
|
+
Array.isArray(content) && typeof content[0] === "object" && content[0]
|
|
406
|
+
? (content[0] as { type?: unknown })
|
|
407
|
+
: undefined;
|
|
408
|
+
return {
|
|
409
|
+
timestamp: new Date(parsed.timestamp as string),
|
|
410
|
+
type: typeof parsed.type === "string" ? parsed.type : undefined,
|
|
411
|
+
message: msg
|
|
412
|
+
? {
|
|
413
|
+
id: typeof msg.id === "string" ? msg.id : undefined,
|
|
414
|
+
model: typeof msg.model === "string" ? msg.model : undefined,
|
|
415
|
+
usage,
|
|
416
|
+
role: typeof msg.role === "string" ? msg.role : undefined,
|
|
417
|
+
type: typeof msg.type === "string" ? msg.type : undefined,
|
|
418
|
+
firstContentType:
|
|
419
|
+
typeof firstBlock?.type === "string" ? firstBlock.type : undefined,
|
|
420
|
+
}
|
|
421
|
+
: undefined,
|
|
422
|
+
costUSD: typeof parsed.costUSD === "number" ? parsed.costUSD : undefined,
|
|
423
|
+
isSidechain: parsed.isSidechain === true,
|
|
424
|
+
raw: {
|
|
425
|
+
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
|
426
|
+
message: msg
|
|
427
|
+
? {
|
|
428
|
+
id: typeof msg.id === "string" ? msg.id : undefined,
|
|
429
|
+
model: typeof msg.model === "string" ? msg.model : undefined,
|
|
430
|
+
usage,
|
|
431
|
+
}
|
|
432
|
+
: undefined,
|
|
433
|
+
requestId:
|
|
434
|
+
typeof parsed.requestId === "string" ? parsed.requestId : undefined,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function parseJsonlFileInMemory(
|
|
440
|
+
filePath: string,
|
|
441
|
+
): Promise<ParsedEntry[]> {
|
|
442
|
+
const content = await readFile(filePath, "utf-8");
|
|
443
|
+
const lines = content
|
|
444
|
+
.trim()
|
|
445
|
+
.split("\n")
|
|
446
|
+
.filter((line) => line.trim());
|
|
447
|
+
const entries: ParsedEntry[] = [];
|
|
448
|
+
|
|
449
|
+
for (const line of lines) {
|
|
450
|
+
try {
|
|
451
|
+
const entry = makeEntry(JSON.parse(line));
|
|
452
|
+
if (entry) entries.push(entry);
|
|
453
|
+
} catch (parseError) {
|
|
454
|
+
debug(`Failed to parse JSONL line: ${parseError}`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return entries;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function parseJsonlFileStreaming(
|
|
463
|
+
filePath: string,
|
|
464
|
+
): Promise<ParsedEntry[]> {
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
const entries: ParsedEntry[] = [];
|
|
467
|
+
const fileStream = createReadStream(filePath, { encoding: "utf8" });
|
|
468
|
+
const rl = createInterface({
|
|
469
|
+
input: fileStream,
|
|
470
|
+
crlfDelay: Infinity,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
rl.on("line", (line) => {
|
|
474
|
+
const trimmedLine = line.trim();
|
|
475
|
+
if (!trimmedLine) return;
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const entry = makeEntry(JSON.parse(trimmedLine));
|
|
479
|
+
if (entry) entries.push(entry);
|
|
480
|
+
} catch (parseError) {
|
|
481
|
+
debug(`Failed to parse JSONL line: ${parseError}`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
rl.on("close", () => {
|
|
486
|
+
resolve(entries);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
rl.on("error", (error) => {
|
|
490
|
+
debug(`Streaming parser error for ${filePath}:`, error);
|
|
491
|
+
reject(error);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
fileStream.on("error", (error) => {
|
|
495
|
+
debug(`File stream error for ${filePath}:`, error);
|
|
496
|
+
reject(error);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
interface FileStat {
|
|
502
|
+
filePath: string;
|
|
503
|
+
mtime: Date;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function statFile(filePath: string): Promise<FileStat | null> {
|
|
507
|
+
try {
|
|
508
|
+
const mtime = await getFileModificationDate(filePath);
|
|
509
|
+
return mtime ? { filePath, mtime } : null;
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function collectProjectFiles(
|
|
516
|
+
projectPath: string,
|
|
517
|
+
fileFilter?: (filePath: string, modTime: Date) => boolean,
|
|
518
|
+
): Promise<FileStat[]> {
|
|
519
|
+
try {
|
|
520
|
+
const entries = await readdir(projectPath, { withFileTypes: true });
|
|
521
|
+
|
|
522
|
+
const sessionFiles = entries
|
|
523
|
+
.filter((e) => !e.isDirectory() && e.name.endsWith(".jsonl"))
|
|
524
|
+
.map((e) => statFile(join(projectPath, e.name)));
|
|
525
|
+
|
|
526
|
+
const subagentFiles = entries
|
|
527
|
+
.filter((e) => e.isDirectory())
|
|
528
|
+
.map(async (e) => {
|
|
529
|
+
const subagentsDir = join(projectPath, e.name, "subagents");
|
|
530
|
+
try {
|
|
531
|
+
const files = await readdir(subagentsDir);
|
|
532
|
+
return files
|
|
533
|
+
.filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"))
|
|
534
|
+
.map((f) => statFile(join(subagentsDir, f)));
|
|
535
|
+
} catch {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const [sessionResults, subagentResults] = await Promise.all([
|
|
541
|
+
Promise.all(sessionFiles),
|
|
542
|
+
Promise.all(subagentFiles).then((nested) => Promise.all(nested.flat())),
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
return [...sessionResults, ...subagentResults].filter(
|
|
546
|
+
(s): s is FileStat =>
|
|
547
|
+
s !== null && (!fileFilter || fileFilter(s.filePath, s.mtime)),
|
|
548
|
+
);
|
|
549
|
+
} catch (dirError) {
|
|
550
|
+
debug(`Failed to read project directory ${projectPath}:`, dirError);
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Loads entries from Claude projects with deterministic deduplication.
|
|
557
|
+
* @param timeFilter Optional filter to apply based on timestamp
|
|
558
|
+
* @param fileFilter Optional filter to apply based on file path and modification time
|
|
559
|
+
* @param sortFiles Whether to sort files by modification time
|
|
560
|
+
* @returns Deduplicated entries sorted by timestamp
|
|
561
|
+
* @note Sorts entries by timestamp before deduplication to ensure consistent
|
|
562
|
+
* duplicate selection. Otherwise, parallel file loading causes race conditions
|
|
563
|
+
* where different duplicates are kept on each run, leading to flickering values.
|
|
564
|
+
*/
|
|
565
|
+
export async function loadEntriesFromProjects(
|
|
566
|
+
timeFilter?: (entry: ParsedEntry) => boolean,
|
|
567
|
+
fileFilter?: (filePath: string, modTime: Date) => boolean,
|
|
568
|
+
sortFiles = false,
|
|
569
|
+
): Promise<ParsedEntry[]> {
|
|
570
|
+
const claudePaths = getClaudePaths();
|
|
571
|
+
const projectPaths = await findProjectPaths(claudePaths);
|
|
572
|
+
const processedHashes = new Set<string>();
|
|
573
|
+
|
|
574
|
+
const allFilesPromises = projectPaths.map((projectPath) =>
|
|
575
|
+
collectProjectFiles(projectPath, fileFilter),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
const allFileResults = await Promise.all(allFilesPromises);
|
|
579
|
+
const allFilesWithMtime = allFileResults
|
|
580
|
+
.flat()
|
|
581
|
+
.filter((file): file is { filePath: string; mtime: Date } => file !== null);
|
|
582
|
+
|
|
583
|
+
if (sortFiles) {
|
|
584
|
+
allFilesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const allFiles = allFilesWithMtime.map((file) => file.filePath);
|
|
588
|
+
|
|
589
|
+
const entries: ParsedEntry[] = [];
|
|
590
|
+
|
|
591
|
+
const filePromises = allFiles.map(async (filePath) => {
|
|
592
|
+
const fileEntries = await parseJsonlFile(filePath);
|
|
593
|
+
return fileEntries.filter((entry) => !timeFilter || timeFilter(entry));
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const fileResults = await Promise.all(filePromises);
|
|
597
|
+
for (const fileEntries of fileResults) {
|
|
598
|
+
entries.push(...fileEntries);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
602
|
+
|
|
603
|
+
const deduplicatedEntries: ParsedEntry[] = [];
|
|
604
|
+
for (const entry of entries) {
|
|
605
|
+
const uniqueHash = createUniqueHash(entry);
|
|
606
|
+
if (uniqueHash && processedHashes.has(uniqueHash)) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (uniqueHash) {
|
|
610
|
+
processedHashes.add(uniqueHash);
|
|
611
|
+
}
|
|
612
|
+
deduplicatedEntries.push(entry);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return deduplicatedEntries;
|
|
616
|
+
}
|