@xamukavila/pxpipe 0.8.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Shared data layer for the dashboard's session views. Node-only (filesystem
3
+ * I/O) — never imported from `src/core/`.
4
+ *
5
+ * ## Why we group by first_user_sha8 (Path B)
6
+ *
7
+ * Every TrackEvent carries `first_user_sha8` (see src/core/tracker.ts), an
8
+ * sha256 prefix of the conversation's first user message. Within a single
9
+ * Claude Code session that hash is stable across every turn; across two
10
+ * different sessions it is virtually never the same. That makes it a
11
+ * better-than-good-enough session key without coupling pxpipe to Claude
12
+ * Code's internal file layout for *correctness*.
13
+ *
14
+ * We *do* read `~/.claude/projects/` opportunistically (see `claudeCodeMap`)
15
+ * to enrich the dashboard with real Claude Code session IDs + project
16
+ * paths — but it's best-effort: missing or unreadable files just leave the
17
+ * synthetic ID standing alone.
18
+ *
19
+ * ## File layout we manage
20
+ *
21
+ * - `~/.pxpipe/events.jsonl` — append-only JSONL written by FileTracker
22
+ * - `~/.pxpipe/4xx-bodies/${iso-ts}-${sha8}.json.gz` — gzipped failure
23
+ * bodies referenced from JSONL rows via `req_body_sample_path`
24
+ */
25
+ import type { TrackEvent } from './core/tracker.js';
26
+ export interface SessionSummary {
27
+ /** The synthetic session ID = first_user_sha8 (or '<unknown>' if missing). */
28
+ id: string;
29
+ /** Working directory of the first event in the session, if any. */
30
+ project: string | undefined;
31
+ /** ISO timestamp of the first event we saw for this session. */
32
+ firstSeen: string;
33
+ /** ISO timestamp of the last event we saw for this session. */
34
+ lastSeen: string;
35
+ /** Number of events recorded against this session. */
36
+ requestCount: number;
37
+ /** `tokensSavedEst × 4` — a coarse byte-equivalent of the token savings,
38
+ * useful only as a rough "we shaved X kB off the wire" callout. Not
39
+ * load-bearing math; the real number is `tokensSavedEst`. */
40
+ charsSaved: number;
41
+ /** Real input-side tokens saved: sum of `baseline_tokens − (input +
42
+ * cache_create×1.25 + cache_read×0.10)` across events that carry both
43
+ * a /v1/messages/count_tokens probe and an upstream usage block.
44
+ * Events missing either side contribute to requestCount but not here.
45
+ * No estimation — can go negative when a compression net-lost. */
46
+ tokensSavedEst: number;
47
+ /** Sum of cache_read_input_tokens — actual prompt-cache hits. */
48
+ cacheReadTokens: number;
49
+ /** Bytes attributable to this session in events.jsonl (sum of line lengths
50
+ * including the trailing newline). */
51
+ jsonlBytes: number;
52
+ /** Bytes attributable to this session in 4xx-bodies/ sidecars. */
53
+ sidecarBytes: number;
54
+ }
55
+ export interface DiskUsage {
56
+ eventsJsonlBytes: number;
57
+ sidecarsBytes: number;
58
+ sidecarCount: number;
59
+ totalBytes: number;
60
+ }
61
+ /** Resolved paths a sessions invocation will touch. Single source of truth so
62
+ * tests can point the whole module at a tmpdir. */
63
+ export interface SessionsPaths {
64
+ eventsFile: string;
65
+ sidecarDir: string;
66
+ }
67
+ export declare function defaultPaths(): SessionsPaths;
68
+ /** Lazily stream events.jsonl line by line. Yields parsed TrackEvents plus
69
+ * the raw line (we need byte length for jsonlBytes accounting). Malformed
70
+ * lines are silently dropped — matches `pxpipe stats` behavior. */
71
+ export declare function readEvents(eventsFile: string): AsyncGenerator<{
72
+ ev: TrackEvent;
73
+ rawBytes: number;
74
+ }>;
75
+ export declare const UNKNOWN_SESSION = "<unknown>";
76
+ export interface AggregateResult {
77
+ sessions: Map<string, SessionSummary>;
78
+ /** sessionId -> set of absolute sidecar paths referenced by its events. */
79
+ sidecarsBySession: Map<string, Set<string>>;
80
+ }
81
+ /** Build a map of sessionId -> SessionSummary by scanning every event. Also
82
+ * tracks which sidecars belong to which session so prune can clean them. */
83
+ export declare function aggregateSessions(paths: SessionsPaths): Promise<AggregateResult>;
84
+ export interface ListOptions {
85
+ /** Substring or basename match against `cwd`. */
86
+ project?: string;
87
+ /** ISO timestamp; only sessions whose lastSeen >= since survive. */
88
+ since?: string;
89
+ }
90
+ /** Sort SessionSummary entries most-recent-first and apply optional filters.
91
+ * Pure: the dashboard maps query-string params straight into ListOptions. */
92
+ export declare function filterSessions(sessions: Map<string, SessionSummary>, opts: ListOptions): SessionSummary[];
93
+ export interface PruneOptions {
94
+ /** Drop sessions whose lastSeen is older than N days. */
95
+ olderThanDays?: number;
96
+ /** Keep only the N most-recently-active sessions. */
97
+ keepLast?: number;
98
+ /** Drop a single session by ID. */
99
+ sessionId?: string;
100
+ /** Drop multiple sessions in one atomic pass — bulk-delete from the
101
+ * dashboard's checkbox UI. Unknown IDs are silently ignored (the
102
+ * caller may have raced a concurrent prune). Coexists with
103
+ * `sessionId` (single) — both contribute to the removal set. */
104
+ sessionIds?: string[];
105
+ /** When true, actually delete. When false (the default), report only. */
106
+ force: boolean;
107
+ }
108
+ export interface PruneReport {
109
+ sessionsRemoved: string[];
110
+ eventsRemoved: number;
111
+ eventsKept: number;
112
+ jsonlBytesFreed: number;
113
+ sidecarsRemoved: number;
114
+ sidecarBytesFreed: number;
115
+ /** True when this was a real run (force=true). False for dry-run. */
116
+ applied: boolean;
117
+ }
118
+ /** Decide which sessions to remove based on the prune options. Pure — no
119
+ * I/O — so it's easy to unit-test against a synthetic aggregation. */
120
+ export declare function selectSessionsToRemove(sessions: Map<string, SessionSummary>, opts: PruneOptions, now?: Date): Set<string>;
121
+ /**
122
+ * Rewrite events.jsonl with rows from `toRemove` sessions stripped out, and
123
+ * delete the matching 4xx-body sidecars. Atomic: writes to a sibling `.tmp`
124
+ * file with fsync, then renames over the original.
125
+ *
126
+ * Concurrency note: if the live proxy appends during prune, those new lines
127
+ * will be lost (the proxy holds an fd to the pre-rename inode and keeps
128
+ * writing to it). For a single-user dev tool that's an acceptable tradeoff;
129
+ * the dashboard's confirm dialog warns the user before the destructive op.
130
+ */
131
+ export declare function prune(paths: SessionsPaths, opts: PruneOptions, now?: Date): Promise<PruneReport>;
132
+ export declare function diskUsage(paths: SessionsPaths): DiskUsage;
133
+ export interface ClaudeCodeSessionRef {
134
+ /** The Claude Code session ID (file basename without .jsonl). */
135
+ sessionId: string;
136
+ /** The decoded project path. Encoded form: `-Users-me-code-foo` →
137
+ * `/Users/me/code/foo`. Best-effort: dashes in actual path segments
138
+ * (e.g. `my-project`) round-trip as slashes, so this is for display
139
+ * only — don't `fs.existsSync` against it. */
140
+ projectPath: string;
141
+ /** First user message text, truncated for display. */
142
+ firstUserPreview: string;
143
+ }
144
+ /** Path where Claude Code stores per-session JSONL transcripts. */
145
+ export declare function claudeProjectsDir(): string;
146
+ /**
147
+ * Compute the sha256 prefix the proxy uses for `first_user_sha8` (see
148
+ * src/core/transform.ts:firstUserText + sha8). Crucially this must match
149
+ * exactly — same 4 KiB cap, same first-8-hex-char prefix — or the map will
150
+ * silently miss every entry.
151
+ */
152
+ export declare function fingerprintFirstUser(text: string): string;
153
+ /** Pull the first user message text out of a single Claude Code session
154
+ * JSONL file. Walks the file line by line and stops at the first row with
155
+ * `type === 'user'` that has parseable user content. */
156
+ export declare function readFirstUserFromClaudeSession(filePath: string): Promise<string | undefined>;
157
+ /** Convert Claude Code's project directory encoding back to a path. The
158
+ * encoding is lossy (every `/`, `_`, and original `-` all become `-` in the
159
+ * directory name) so this is display-only. */
160
+ export declare function decodeClaudeProjectDir(name: string): string;
161
+ /**
162
+ * Best-effort scan of `~/.claude/projects/*.jsonl`. Returns a map keyed by
163
+ * the same `first_user_sha8` the proxy emits. If `~/.claude/projects/` is
164
+ * missing, returns an empty map without throwing — pxpipe must keep
165
+ * working for non-Claude-Code clients.
166
+ *
167
+ * This is O(number_of_sessions) file opens. On a heavy user's machine
168
+ * that's a few hundred small reads — well under a second on an SSD. We
169
+ * don't poll continuously; the dashboard re-invokes this on each refresh.
170
+ */
171
+ export declare function claudeCodeMap(rootDir?: string): Promise<Map<string, ClaudeCodeSessionRef>>;
172
+ //# sourceMappingURL=sessions.d.ts.map
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Shared data layer for the dashboard's session views. Node-only (filesystem
3
+ * I/O) — never imported from `src/core/`.
4
+ *
5
+ * ## Why we group by first_user_sha8 (Path B)
6
+ *
7
+ * Every TrackEvent carries `first_user_sha8` (see src/core/tracker.ts), an
8
+ * sha256 prefix of the conversation's first user message. Within a single
9
+ * Claude Code session that hash is stable across every turn; across two
10
+ * different sessions it is virtually never the same. That makes it a
11
+ * better-than-good-enough session key without coupling pxpipe to Claude
12
+ * Code's internal file layout for *correctness*.
13
+ *
14
+ * We *do* read `~/.claude/projects/` opportunistically (see `claudeCodeMap`)
15
+ * to enrich the dashboard with real Claude Code session IDs + project
16
+ * paths — but it's best-effort: missing or unreadable files just leave the
17
+ * synthetic ID standing alone.
18
+ *
19
+ * ## File layout we manage
20
+ *
21
+ * - `~/.pxpipe/events.jsonl` — append-only JSONL written by FileTracker
22
+ * - `~/.pxpipe/4xx-bodies/${iso-ts}-${sha8}.json.gz` — gzipped failure
23
+ * bodies referenced from JSONL rows via `req_body_sample_path`
24
+ */
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import * as os from 'node:os';
28
+ import * as crypto from 'node:crypto';
29
+ import * as readline from 'node:readline';
30
+ import { computeActualInputEff, computeBaselineInputEff, deriveBaselineWarmth, } from './core/baseline.js';
31
+ export function defaultPaths() {
32
+ const home = os.homedir();
33
+ const eventsFile = process.env.PXPIPE_LOG ?? path.join(home, '.pxpipe', 'events.jsonl');
34
+ // The sidecar directory is `4xx-bodies` next to the events file, matching
35
+ // what src/node.ts writes.
36
+ const sidecarDir = path.join(path.dirname(eventsFile), '4xx-bodies');
37
+ return { eventsFile, sidecarDir };
38
+ }
39
+ // ---- Core reader -----------------------------------------------------------
40
+ /** Lazily stream events.jsonl line by line. Yields parsed TrackEvents plus
41
+ * the raw line (we need byte length for jsonlBytes accounting). Malformed
42
+ * lines are silently dropped — matches `pxpipe stats` behavior. */
43
+ export async function* readEvents(eventsFile) {
44
+ if (!fs.existsSync(eventsFile))
45
+ return;
46
+ const stream = fs.createReadStream(eventsFile, { encoding: 'utf8' });
47
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
48
+ for await (const line of rl) {
49
+ if (!line.trim())
50
+ continue;
51
+ let ev;
52
+ try {
53
+ ev = JSON.parse(line);
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ // +1 for the newline FileTracker writes after each row.
59
+ yield { ev, rawBytes: Buffer.byteLength(line, 'utf8') + 1 };
60
+ }
61
+ }
62
+ // ---- Aggregation -----------------------------------------------------------
63
+ export const UNKNOWN_SESSION = '<unknown>';
64
+ function sessionIdOf(ev) {
65
+ return ev.first_user_sha8 ?? UNKNOWN_SESSION;
66
+ }
67
+ /** Build a map of sessionId -> SessionSummary by scanning every event. Also
68
+ * tracks which sidecars belong to which session so prune can clean them. */
69
+ export async function aggregateSessions(paths) {
70
+ const sessions = new Map();
71
+ const sidecarsBySession = new Map();
72
+ // Per-session prior prefix sizes for the cache-aware text counterfactual.
73
+ // Warm/cold comes only from server-observed cache_read; this map only refines
74
+ // reused/grown splitting after cr>0 has proved warmth. Kept out of
75
+ // SessionSummary so it never leaks into the /api/sessions.json shape.
76
+ const warmth = new Map();
77
+ const CACHE_TTL_SEC = 300;
78
+ // Stat sidecar sizes once up front. Looking up size per event would be
79
+ // O(N²) syscalls; the directory is small enough to read fully.
80
+ const sidecarSizes = sidecarFileSizes(paths.sidecarDir);
81
+ for await (const { ev, rawBytes } of readEvents(paths.eventsFile)) {
82
+ const id = sessionIdOf(ev);
83
+ let s = sessions.get(id);
84
+ if (!s) {
85
+ s = {
86
+ id,
87
+ project: ev.cwd,
88
+ firstSeen: ev.ts,
89
+ lastSeen: ev.ts,
90
+ requestCount: 0,
91
+ charsSaved: 0,
92
+ tokensSavedEst: 0,
93
+ cacheReadTokens: 0,
94
+ jsonlBytes: 0,
95
+ sidecarBytes: 0,
96
+ };
97
+ sessions.set(id, s);
98
+ }
99
+ s.requestCount++;
100
+ s.jsonlBytes += rawBytes;
101
+ if (ev.ts < s.firstSeen)
102
+ s.firstSeen = ev.ts;
103
+ if (ev.ts > s.lastSeen)
104
+ s.lastSeen = ev.ts;
105
+ // Cling to whichever cwd we saw first; sessions that hop directories are
106
+ // rare and the first cwd is the most stable identifier.
107
+ if (s.project === undefined && ev.cwd)
108
+ s.project = ev.cwd;
109
+ // Real per-session savings, cache-aware. See src/core/baseline.ts for the
110
+ // full derivation: pxpipe is credited only for token reduction, never for
111
+ // caching. The imagined text baseline gets the SAME observed cache state as
112
+ // the actual request: cr>0 means warm for both, cr===0 means cold for both.
113
+ // Events missing either probe stay out of the rollup — no estimation.
114
+ const inp = ev.input_tokens ?? 0;
115
+ const cc = ev.cache_create_tokens ?? 0;
116
+ const cr = ev.cache_read_tokens ?? 0;
117
+ const haveUsage = inp > 0 || cc > 0 || cr > 0;
118
+ const baseline = ev.baseline_tokens;
119
+ if (typeof baseline === 'number' &&
120
+ baseline > 0 &&
121
+ haveUsage) {
122
+ const cacheable = ev.baseline_cacheable_tokens ?? 0;
123
+ const prefixSha = ev.system_sha8;
124
+ const completionSec = Date.parse(ev.ts) / 1000;
125
+ const requestStartSec = completionSec - Math.max(0, ev.duration_ms || 0) / 1000;
126
+ const prev = warmth.get(id);
127
+ // Warmth is cr-only; a completed same-prefix prior only refines the
128
+ // reused/grown split after cr>0 has proved warmth.
129
+ const { warm, prevCacheable } = deriveBaselineWarmth(prev, requestStartSec, cacheable, cr, CACHE_TTL_SEC, prefixSha);
130
+ const baselineEff = computeBaselineInputEff(baseline, cacheable, inp, cc, cr, warm, prevCacheable);
131
+ const actualEff = computeActualInputEff(inp, cc, cr);
132
+ const tokensSaved = baselineEff - actualEff;
133
+ s.tokensSavedEst += Math.round(tokensSaved);
134
+ s.charsSaved += Math.round(tokensSaved * 4);
135
+ }
136
+ // Record this completed row's prefix size for future cr>0 split estimates.
137
+ // Carry prior cacheable when this row had no probe.
138
+ if (haveUsage) {
139
+ const completionSec = Date.parse(ev.ts) / 1000;
140
+ const cacheable = ev.baseline_cacheable_tokens ?? 0;
141
+ const prefixSha = ev.system_sha8;
142
+ const prev = warmth.get(id);
143
+ warmth.set(id, {
144
+ ts: completionSec,
145
+ cacheable: cacheable > 0 ? cacheable : (prev?.cacheable ?? 0),
146
+ prefixSha: prefixSha ?? prev?.prefixSha,
147
+ });
148
+ }
149
+ if (typeof ev.cache_read_tokens === 'number') {
150
+ s.cacheReadTokens += ev.cache_read_tokens;
151
+ }
152
+ if (ev.req_body_sample_path) {
153
+ let set = sidecarsBySession.get(id);
154
+ if (!set) {
155
+ set = new Set();
156
+ sidecarsBySession.set(id, set);
157
+ }
158
+ set.add(ev.req_body_sample_path);
159
+ const size = sidecarSizes.get(ev.req_body_sample_path);
160
+ if (typeof size === 'number')
161
+ s.sidecarBytes += size;
162
+ }
163
+ }
164
+ return { sessions, sidecarsBySession };
165
+ }
166
+ /** Sort SessionSummary entries most-recent-first and apply optional filters.
167
+ * Pure: the dashboard maps query-string params straight into ListOptions. */
168
+ export function filterSessions(sessions, opts) {
169
+ return [...sessions.values()]
170
+ .filter((s) => {
171
+ if (opts.project) {
172
+ if (!s.project)
173
+ return false;
174
+ if (s.project !== opts.project &&
175
+ path.basename(s.project) !== opts.project &&
176
+ !s.project.includes(opts.project)) {
177
+ return false;
178
+ }
179
+ }
180
+ if (opts.since && s.lastSeen < opts.since)
181
+ return false;
182
+ return true;
183
+ })
184
+ .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
185
+ }
186
+ function sidecarFileSizes(dir) {
187
+ const out = new Map();
188
+ if (!fs.existsSync(dir))
189
+ return out;
190
+ let entries;
191
+ try {
192
+ entries = fs.readdirSync(dir);
193
+ }
194
+ catch {
195
+ return out;
196
+ }
197
+ for (const name of entries) {
198
+ const full = path.join(dir, name);
199
+ try {
200
+ const st = fs.statSync(full);
201
+ if (st.isFile())
202
+ out.set(full, st.size);
203
+ }
204
+ catch {
205
+ /* concurrent delete is fine */
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ /** Decide which sessions to remove based on the prune options. Pure — no
211
+ * I/O — so it's easy to unit-test against a synthetic aggregation. */
212
+ export function selectSessionsToRemove(sessions, opts, now = new Date()) {
213
+ const toRemove = new Set();
214
+ const all = [...sessions.values()];
215
+ if (opts.sessionId) {
216
+ if (sessions.has(opts.sessionId))
217
+ toRemove.add(opts.sessionId);
218
+ }
219
+ if (Array.isArray(opts.sessionIds)) {
220
+ for (const id of opts.sessionIds) {
221
+ // Silently skip unknown IDs — the client may have raced a concurrent
222
+ // prune. The report will reflect what we actually removed.
223
+ if (typeof id === 'string' && sessions.has(id))
224
+ toRemove.add(id);
225
+ }
226
+ }
227
+ if (typeof opts.olderThanDays === 'number') {
228
+ const cutoff = new Date(now.getTime() - opts.olderThanDays * 24 * 60 * 60 * 1000).toISOString();
229
+ for (const s of all) {
230
+ if (s.lastSeen < cutoff)
231
+ toRemove.add(s.id);
232
+ }
233
+ }
234
+ if (typeof opts.keepLast === 'number') {
235
+ // Most-recently-active first; everything after keepLast goes.
236
+ const sorted = [...all].sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
237
+ for (const s of sorted.slice(opts.keepLast))
238
+ toRemove.add(s.id);
239
+ }
240
+ return toRemove;
241
+ }
242
+ /**
243
+ * Rewrite events.jsonl with rows from `toRemove` sessions stripped out, and
244
+ * delete the matching 4xx-body sidecars. Atomic: writes to a sibling `.tmp`
245
+ * file with fsync, then renames over the original.
246
+ *
247
+ * Concurrency note: if the live proxy appends during prune, those new lines
248
+ * will be lost (the proxy holds an fd to the pre-rename inode and keeps
249
+ * writing to it). For a single-user dev tool that's an acceptable tradeoff;
250
+ * the dashboard's confirm dialog warns the user before the destructive op.
251
+ */
252
+ export async function prune(paths, opts, now = new Date()) {
253
+ const { sessions, sidecarsBySession } = await aggregateSessions(paths);
254
+ const toRemove = selectSessionsToRemove(sessions, opts, now);
255
+ let jsonlBytesFreed = 0;
256
+ let eventsRemoved = 0;
257
+ let eventsKept = 0;
258
+ for (const s of sessions.values()) {
259
+ if (toRemove.has(s.id)) {
260
+ jsonlBytesFreed += s.jsonlBytes;
261
+ eventsRemoved += s.requestCount;
262
+ }
263
+ else {
264
+ eventsKept += s.requestCount;
265
+ }
266
+ }
267
+ // Collect sidecar paths up for deletion (and their on-disk sizes).
268
+ const sidecarsToDelete = [];
269
+ for (const id of toRemove) {
270
+ const set = sidecarsBySession.get(id);
271
+ if (!set)
272
+ continue;
273
+ for (const p of set) {
274
+ try {
275
+ const st = fs.statSync(p);
276
+ sidecarsToDelete.push({ path: p, size: st.size });
277
+ }
278
+ catch {
279
+ /* already gone — fine */
280
+ }
281
+ }
282
+ }
283
+ const sidecarBytesFreed = sidecarsToDelete.reduce((n, s) => n + s.size, 0);
284
+ const report = {
285
+ sessionsRemoved: [...toRemove],
286
+ eventsRemoved,
287
+ eventsKept,
288
+ jsonlBytesFreed,
289
+ sidecarsRemoved: sidecarsToDelete.length,
290
+ sidecarBytesFreed,
291
+ applied: false,
292
+ };
293
+ if (!opts.force)
294
+ return report;
295
+ if (toRemove.size === 0) {
296
+ report.applied = true;
297
+ return report;
298
+ }
299
+ // Atomic rewrite: stream the original through a filter into events.jsonl.tmp,
300
+ // fsync, then rename. Any partial state on crash leaves the original intact.
301
+ await rewriteEventsFile(paths.eventsFile, toRemove);
302
+ for (const { path: p } of sidecarsToDelete) {
303
+ try {
304
+ fs.unlinkSync(p);
305
+ }
306
+ catch {
307
+ /* ignore — leftover sidecars are harmless */
308
+ }
309
+ }
310
+ report.applied = true;
311
+ return report;
312
+ }
313
+ async function rewriteEventsFile(eventsFile, toRemove) {
314
+ if (!fs.existsSync(eventsFile))
315
+ return;
316
+ const tmp = eventsFile + '.tmp';
317
+ // Open with 'w' (truncate). We never reuse a stale .tmp.
318
+ const outFd = fs.openSync(tmp, 'w');
319
+ try {
320
+ const stream = fs.createReadStream(eventsFile, { encoding: 'utf8' });
321
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
322
+ for await (const line of rl) {
323
+ if (!line.trim())
324
+ continue;
325
+ let ev;
326
+ try {
327
+ ev = JSON.parse(line);
328
+ }
329
+ catch {
330
+ // Preserve malformed lines so we don't silently eat data we can't parse.
331
+ fs.writeSync(outFd, line + '\n');
332
+ continue;
333
+ }
334
+ if (toRemove.has(sessionIdOf(ev)))
335
+ continue;
336
+ fs.writeSync(outFd, line + '\n');
337
+ }
338
+ fs.fsyncSync(outFd);
339
+ }
340
+ finally {
341
+ fs.closeSync(outFd);
342
+ }
343
+ fs.renameSync(tmp, eventsFile);
344
+ }
345
+ // ---- disk usage ------------------------------------------------------------
346
+ export function diskUsage(paths) {
347
+ let eventsJsonlBytes = 0;
348
+ try {
349
+ eventsJsonlBytes = fs.statSync(paths.eventsFile).size;
350
+ }
351
+ catch {
352
+ /* no events file — leave at 0 */
353
+ }
354
+ const sidecarSizes = sidecarFileSizes(paths.sidecarDir);
355
+ let sidecarsBytes = 0;
356
+ for (const s of sidecarSizes.values())
357
+ sidecarsBytes += s;
358
+ return {
359
+ eventsJsonlBytes,
360
+ sidecarsBytes,
361
+ sidecarCount: sidecarSizes.size,
362
+ totalBytes: eventsJsonlBytes + sidecarsBytes,
363
+ };
364
+ }
365
+ /** Path where Claude Code stores per-session JSONL transcripts. */
366
+ export function claudeProjectsDir() {
367
+ return path.join(os.homedir(), '.claude', 'projects');
368
+ }
369
+ /**
370
+ * Compute the sha256 prefix the proxy uses for `first_user_sha8` (see
371
+ * src/core/transform.ts:firstUserText + sha8). Crucially this must match
372
+ * exactly — same 4 KiB cap, same first-8-hex-char prefix — or the map will
373
+ * silently miss every entry.
374
+ */
375
+ export function fingerprintFirstUser(text) {
376
+ const trimmed = text.slice(0, 4096);
377
+ const hash = crypto.createHash('sha256').update(trimmed, 'utf8').digest('hex');
378
+ return hash.slice(0, 8);
379
+ }
380
+ /** Pull the first user message text out of a single Claude Code session
381
+ * JSONL file. Walks the file line by line and stops at the first row with
382
+ * `type === 'user'` that has parseable user content. */
383
+ export async function readFirstUserFromClaudeSession(filePath) {
384
+ // createReadStream doesn't throw synchronously for ENOENT — the error fires
385
+ // on the 'error' event when read() starts. Pre-check with existsSync so
386
+ // we can return undefined cleanly without an unhandled rejection.
387
+ if (!fs.existsSync(filePath))
388
+ return undefined;
389
+ let stream;
390
+ try {
391
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' });
392
+ }
393
+ catch {
394
+ return undefined;
395
+ }
396
+ // Defensive: still attach an error handler so a permission/race-deletion
397
+ // surface doesn't bubble out of readline's async iterator.
398
+ stream.on('error', () => {
399
+ /* swallow — the iterator will end with no rows */
400
+ });
401
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
402
+ try {
403
+ for await (const line of rl) {
404
+ if (!line.trim())
405
+ continue;
406
+ let row;
407
+ try {
408
+ row = JSON.parse(line);
409
+ }
410
+ catch {
411
+ continue;
412
+ }
413
+ if (!row || typeof row !== 'object')
414
+ continue;
415
+ const r = row;
416
+ if (r.type !== 'user')
417
+ continue;
418
+ const msg = r.message;
419
+ if (!msg || typeof msg !== 'object') {
420
+ // Older Claude Code format: content may live at the top level.
421
+ const content = r.content;
422
+ if (typeof content === 'string')
423
+ return content;
424
+ return undefined;
425
+ }
426
+ const content = msg.content;
427
+ if (typeof content === 'string')
428
+ return content;
429
+ if (Array.isArray(content)) {
430
+ for (const block of content) {
431
+ if (block &&
432
+ typeof block === 'object' &&
433
+ block.type === 'text') {
434
+ const t = block.text;
435
+ if (typeof t === 'string')
436
+ return t;
437
+ }
438
+ }
439
+ }
440
+ // Found a user row but couldn't read it — give up on this file so we
441
+ // don't accidentally hash a later message and produce a wrong mapping.
442
+ return undefined;
443
+ }
444
+ }
445
+ finally {
446
+ stream.close();
447
+ }
448
+ return undefined;
449
+ }
450
+ /** Convert Claude Code's project directory encoding back to a path. The
451
+ * encoding is lossy (every `/`, `_`, and original `-` all become `-` in the
452
+ * directory name) so this is display-only. */
453
+ export function decodeClaudeProjectDir(name) {
454
+ if (name.startsWith('-'))
455
+ return '/' + name.slice(1).replaceAll('-', '/');
456
+ return name.replaceAll('-', '/');
457
+ }
458
+ /**
459
+ * Best-effort scan of `~/.claude/projects/*.jsonl`. Returns a map keyed by
460
+ * the same `first_user_sha8` the proxy emits. If `~/.claude/projects/` is
461
+ * missing, returns an empty map without throwing — pxpipe must keep
462
+ * working for non-Claude-Code clients.
463
+ *
464
+ * This is O(number_of_sessions) file opens. On a heavy user's machine
465
+ * that's a few hundred small reads — well under a second on an SSD. We
466
+ * don't poll continuously; the dashboard re-invokes this on each refresh.
467
+ */
468
+ export async function claudeCodeMap(rootDir = claudeProjectsDir()) {
469
+ const out = new Map();
470
+ let projects;
471
+ try {
472
+ projects = fs.readdirSync(rootDir, { withFileTypes: true });
473
+ }
474
+ catch {
475
+ return out; // No Claude Code install or unreadable — that's fine.
476
+ }
477
+ for (const proj of projects) {
478
+ if (!proj.isDirectory())
479
+ continue;
480
+ const projDir = path.join(rootDir, proj.name);
481
+ let sessions;
482
+ try {
483
+ sessions = fs.readdirSync(projDir);
484
+ }
485
+ catch {
486
+ continue;
487
+ }
488
+ for (const sess of sessions) {
489
+ if (!sess.endsWith('.jsonl'))
490
+ continue;
491
+ const file = path.join(projDir, sess);
492
+ const firstUser = await readFirstUserFromClaudeSession(file);
493
+ if (!firstUser)
494
+ continue;
495
+ const sha8 = fingerprintFirstUser(firstUser);
496
+ // First write wins. If two sessions in different projects start with
497
+ // the same user prompt (e.g. "hi"), the first one we read keeps the
498
+ // slot — at least the dashboard shows *some* CC ref rather than none.
499
+ if (!out.has(sha8)) {
500
+ out.set(sha8, {
501
+ sessionId: sess.replace(/\.jsonl$/, ''),
502
+ projectPath: decodeClaudeProjectDir(proj.name),
503
+ firstUserPreview: firstUser.slice(0, 120),
504
+ });
505
+ }
506
+ }
507
+ }
508
+ return out;
509
+ }
510
+ //# sourceMappingURL=sessions.js.map