ccprofiles-core 0.5.1 → 0.7.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/dist/sessions.js CHANGED
@@ -1,11 +1,54 @@
1
1
  import { readdir, readFile, stat, lstat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,160}$/;
5
+ const MAX_ENTRY_CHARS = 100_000;
6
+ const claudeSessionCache = new Map();
7
+ const codexSessionCache = new Map();
8
+ /** Per-file cap: a transcript above this size is summarized without reading its content. */
9
+ const MAX_FILE_SCAN_BYTES = 10 * 1024 * 1024;
10
+ /** Per-request cap: once this many bytes have been read from disk in one scanSessions() call,
11
+ * remaining (uncached) files are summarized without reading their content either. */
12
+ const MAX_TOTAL_SCAN_BYTES = 50 * 1024 * 1024;
13
+ let scanCacheHits = 0;
14
+ let scanCacheMisses = 0;
15
+ /** Test/observability hook: cumulative cache hit/miss counts across all scanSessions() calls
16
+ * in this process. Not reset automatically — callers that need deltas should diff two reads. */
17
+ export function sessionScanCacheStats() {
18
+ return { hits: scanCacheHits, misses: scanCacheMisses };
19
+ }
4
20
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
5
21
  export function decodeProjectDir(name) {
6
22
  return name.replace(/^-/, '/').replace(/-/g, '/');
7
23
  }
8
- async function parseClaudeSession(file) {
24
+ /**
25
+ * Shared mtime+size cache lookup for the two session-file formats. `parseFull` runs only on a
26
+ * cache miss with a fresh read; `makeTruncated` builds a summary-only result when the file is
27
+ * over the per-file cap or the per-request byte budget is exhausted. A size-cap truncation is
28
+ * deterministic (depends only on the file) and gets cached; a budget-cap truncation depends on
29
+ * request ordering, so it is *not* cached — a later request with a fresh budget may read it fully.
30
+ */
31
+ async function cachedParse(file, cache, budget, parseFull, makeTruncated) {
32
+ let st;
33
+ try {
34
+ st = await stat(file);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ const cached = cache.get(file);
40
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
41
+ scanCacheHits++;
42
+ return cached.result;
43
+ }
44
+ scanCacheMisses++;
45
+ if (st.size > MAX_FILE_SCAN_BYTES) {
46
+ const result = makeTruncated(st);
47
+ cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
48
+ return result;
49
+ }
50
+ if (st.size > budget.bytesLeft)
51
+ return makeTruncated(st); // not cached — budget is per-request
9
52
  let raw;
10
53
  try {
11
54
  raw = await readFile(file, 'utf8');
@@ -13,36 +56,43 @@ async function parseClaudeSession(file) {
13
56
  catch {
14
57
  return null;
15
58
  }
16
- const st = await stat(file);
17
- const lines = raw.split('\n').filter(l => l.trim());
18
- let firstPrompt = null, gitBranch = null, model = null, cwd = null;
19
- for (const line of lines) {
20
- let rec;
21
- try {
22
- rec = JSON.parse(line);
23
- }
24
- catch {
25
- continue;
26
- }
27
- if (cwd === null && typeof rec.cwd === 'string')
28
- cwd = rec.cwd;
29
- if (gitBranch === null && typeof rec.gitBranch === 'string')
30
- gitBranch = rec.gitBranch;
31
- if (firstPrompt === null && rec.type === 'user') {
32
- const c = rec.message?.content;
33
- const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
34
- if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
35
- firstPrompt = text.trim().slice(0, 200);
36
- }
37
- if (model === null && typeof rec.message?.model === 'string')
38
- model = rec.message.model;
39
- if (firstPrompt && model && gitBranch && cwd)
40
- break;
41
- }
59
+ budget.bytesLeft -= st.size;
60
+ const result = parseFull(raw, st);
61
+ cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
62
+ return result;
63
+ }
64
+ async function parseClaudeSession(file, budget) {
42
65
  const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
43
- return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
66
+ return cachedParse(file, claudeSessionCache, budget, (raw, st) => {
67
+ const lines = raw.split('\n').filter(l => l.trim());
68
+ let firstPrompt = null, gitBranch = null, model = null, cwd = null;
69
+ for (const line of lines) {
70
+ let rec;
71
+ try {
72
+ rec = JSON.parse(line);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (cwd === null && typeof rec.cwd === 'string')
78
+ cwd = rec.cwd;
79
+ if (gitBranch === null && typeof rec.gitBranch === 'string')
80
+ gitBranch = rec.gitBranch;
81
+ if (firstPrompt === null && rec.type === 'user') {
82
+ const c = rec.message?.content;
83
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
84
+ if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
85
+ firstPrompt = text.trim().slice(0, 200);
86
+ }
87
+ if (model === null && typeof rec.message?.model === 'string')
88
+ model = rec.message.model;
89
+ if (firstPrompt && model && gitBranch && cwd)
90
+ break;
91
+ }
92
+ return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
93
+ }, st => ({ meta: { id, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: null }));
44
94
  }
45
- async function scanProjectsDir(projectsDir, scope) {
95
+ async function scanProjectsDir(projectsDir, scope, budget) {
46
96
  if (!existsSync(projectsDir))
47
97
  return [];
48
98
  const out = [];
@@ -69,7 +119,7 @@ async function scanProjectsDir(projectsDir, scope) {
69
119
  for (const f of files) {
70
120
  if (!f.endsWith('.jsonl'))
71
121
  continue;
72
- const parsed = await parseClaudeSession(join(pdir, f));
122
+ const parsed = await parseClaudeSession(join(pdir, f), budget);
73
123
  if (!parsed)
74
124
  continue;
75
125
  sessions.push(parsed.meta);
@@ -88,10 +138,40 @@ function messageText(content) {
88
138
  return content;
89
139
  if (!Array.isArray(content))
90
140
  return null;
91
- const item = content.find((x) => x?.type === 'input_text' || x?.type === 'text');
92
- return typeof item?.text === 'string' ? item.text : null;
141
+ const texts = content
142
+ .filter((x) => x?.type === 'input_text' || x?.type === 'output_text' || x?.type === 'text')
143
+ .map((x) => x.text)
144
+ .filter((x) => typeof x === 'string');
145
+ return texts.length ? texts.join('\n\n') : null;
146
+ }
147
+ function limited(text) {
148
+ return text.length <= MAX_ENTRY_CHARS ? text : `${text.slice(0, MAX_ENTRY_CHARS)}\n\n[entry truncated for display]`;
149
+ }
150
+ function toolText(value) {
151
+ if (typeof value === 'string')
152
+ return value;
153
+ try {
154
+ return JSON.stringify(value, null, 2);
155
+ }
156
+ catch {
157
+ return String(value);
158
+ }
159
+ }
160
+ function pushEntry(messages, role, text, label, timestamp) {
161
+ const clean = text?.trim();
162
+ if (!clean)
163
+ return;
164
+ if (role === 'user' && /^<(system-reminder|local-command-caveat|command-name)>/i.test(clean))
165
+ return;
166
+ const previous = messages.at(-1);
167
+ if (previous?.role === role && previous.text === clean)
168
+ return;
169
+ messages.push({
170
+ id: String(messages.length + 1), role, text: limited(clean), label,
171
+ timestamp: typeof timestamp === 'string' ? timestamp : null,
172
+ });
93
173
  }
94
- async function parseCodexSession(file) {
174
+ async function parseClaudeTranscript(file, scope) {
95
175
  let raw;
96
176
  try {
97
177
  raw = await readFile(file, 'utf8');
@@ -99,11 +179,12 @@ async function parseCodexSession(file) {
99
179
  catch {
100
180
  return null;
101
181
  }
102
- const st = await stat(file);
103
- const lines = raw.split('\n').filter(l => l.trim());
104
- let id = null, firstPrompt = null, gitBranch = null;
105
- let model = null, cwd = null, messageCount = 0;
106
- for (const line of lines) {
182
+ const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
183
+ const messages = [];
184
+ let project = '(unknown project)';
185
+ for (const line of raw.split('\n')) {
186
+ if (!line.trim())
187
+ continue;
107
188
  let rec;
108
189
  try {
109
190
  rec = JSON.parse(line);
@@ -111,6 +192,51 @@ async function parseCodexSession(file) {
111
192
  catch {
112
193
  continue;
113
194
  }
195
+ if (project === '(unknown project)' && typeof rec.cwd === 'string')
196
+ project = rec.cwd;
197
+ const content = rec.message?.content;
198
+ if (rec.type === 'user') {
199
+ pushEntry(messages, 'user', messageText(content), null, rec.timestamp);
200
+ if (Array.isArray(content))
201
+ for (const block of content) {
202
+ if (block?.type === 'tool_result')
203
+ pushEntry(messages, 'tool', messageText(block.content) ?? toolText(block.content), 'Tool result', rec.timestamp);
204
+ }
205
+ }
206
+ else if (rec.type === 'assistant') {
207
+ pushEntry(messages, 'assistant', messageText(content), null, rec.timestamp);
208
+ if (Array.isArray(content))
209
+ for (const block of content) {
210
+ if (block?.type === 'tool_use')
211
+ pushEntry(messages, 'tool', toolText(block.input), typeof block.name === 'string' ? block.name : 'Tool call', rec.timestamp);
212
+ }
213
+ }
214
+ }
215
+ return { id, agent: 'claude', scope, project, messages };
216
+ }
217
+ async function parseCodexTranscript(file, scope) {
218
+ let raw;
219
+ try {
220
+ raw = await readFile(file, 'utf8');
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ const records = [];
226
+ for (const line of raw.split('\n')) {
227
+ if (!line.trim())
228
+ continue;
229
+ try {
230
+ records.push(JSON.parse(line));
231
+ }
232
+ catch { /* malformed line */ }
233
+ }
234
+ const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
235
+ let id = filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
236
+ let project = '(unknown project)';
237
+ const hasResponseMessages = records.some(rec => rec?.type === 'response_item' && rec?.payload?.type === 'message');
238
+ const messages = [];
239
+ for (const rec of records) {
114
240
  const payload = rec?.payload;
115
241
  if (rec?.type === 'session_meta') {
116
242
  if (typeof payload?.id === 'string')
@@ -118,33 +244,77 @@ async function parseCodexSession(file) {
118
244
  else if (typeof payload?.session_id === 'string')
119
245
  id = payload.session_id;
120
246
  if (typeof payload?.cwd === 'string')
121
- cwd = payload.cwd;
122
- if (typeof payload?.git?.branch === 'string')
123
- gitBranch = payload.git.branch;
124
- }
125
- if (rec?.type === 'turn_context') {
126
- if (cwd === null && typeof payload?.cwd === 'string')
127
- cwd = payload.cwd;
128
- if (model === null && typeof payload?.model === 'string')
129
- model = payload.model;
130
- }
131
- if (rec?.type === 'response_item' && payload?.type === 'message'
132
- && (payload?.role === 'user' || payload?.role === 'assistant')) {
133
- messageCount++;
134
- if (firstPrompt === null && payload.role === 'user') {
135
- const text = messageText(payload.content);
136
- if (text?.trim() && !text.trimStart().startsWith('<'))
137
- firstPrompt = text.trim().slice(0, 200);
138
- }
247
+ project = payload.cwd;
248
+ continue;
249
+ }
250
+ if (project === '(unknown project)' && rec?.type === 'turn_context' && typeof payload?.cwd === 'string')
251
+ project = payload.cwd;
252
+ if (rec?.type === 'response_item') {
253
+ if (payload?.type === 'message' && (payload.role === 'user' || payload.role === 'assistant'))
254
+ pushEntry(messages, payload.role, messageText(payload.content), null, rec.timestamp);
255
+ else if (payload?.type === 'function_call' || payload?.type === 'custom_tool_call' || payload?.type === 'local_shell_call')
256
+ pushEntry(messages, 'tool', toolText(payload.arguments ?? payload.input ?? payload), payload.name ?? payload.type, rec.timestamp);
257
+ else if (payload?.type === 'function_call_output' || payload?.type === 'custom_tool_call_output')
258
+ pushEntry(messages, 'tool', toolText(payload.output), 'Tool result', rec.timestamp);
139
259
  }
140
- if (firstPrompt === null && rec?.type === 'event_msg' && payload?.type === 'user_message'
141
- && typeof payload?.message === 'string' && payload.message.trim()) {
142
- firstPrompt = payload.message.trim().slice(0, 200);
260
+ else if (!hasResponseMessages && rec?.type === 'event_msg') {
261
+ if (payload?.type === 'user_message')
262
+ pushEntry(messages, 'user', payload.message, null, rec.timestamp);
263
+ else if (payload?.type === 'agent_message')
264
+ pushEntry(messages, 'assistant', payload.message, null, rec.timestamp);
143
265
  }
144
266
  }
267
+ return { id, agent: 'codex', scope, project, messages };
268
+ }
269
+ async function parseCodexSession(file, budget) {
145
270
  const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
146
- id ??= filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
147
- return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
271
+ const fallbackId = filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
272
+ return cachedParse(file, codexSessionCache, budget, (raw, st) => {
273
+ const lines = raw.split('\n').filter(l => l.trim());
274
+ let id = null, firstPrompt = null, gitBranch = null;
275
+ let model = null, cwd = null, messageCount = 0;
276
+ for (const line of lines) {
277
+ let rec;
278
+ try {
279
+ rec = JSON.parse(line);
280
+ }
281
+ catch {
282
+ continue;
283
+ }
284
+ const payload = rec?.payload;
285
+ if (rec?.type === 'session_meta') {
286
+ if (typeof payload?.id === 'string')
287
+ id = payload.id;
288
+ else if (typeof payload?.session_id === 'string')
289
+ id = payload.session_id;
290
+ if (typeof payload?.cwd === 'string')
291
+ cwd = payload.cwd;
292
+ if (typeof payload?.git?.branch === 'string')
293
+ gitBranch = payload.git.branch;
294
+ }
295
+ if (rec?.type === 'turn_context') {
296
+ if (cwd === null && typeof payload?.cwd === 'string')
297
+ cwd = payload.cwd;
298
+ if (model === null && typeof payload?.model === 'string')
299
+ model = payload.model;
300
+ }
301
+ if (rec?.type === 'response_item' && payload?.type === 'message'
302
+ && (payload?.role === 'user' || payload?.role === 'assistant')) {
303
+ messageCount++;
304
+ if (firstPrompt === null && payload.role === 'user') {
305
+ const text = messageText(payload.content);
306
+ if (text?.trim() && !text.trimStart().startsWith('<'))
307
+ firstPrompt = text.trim().slice(0, 200);
308
+ }
309
+ }
310
+ if (firstPrompt === null && rec?.type === 'event_msg' && payload?.type === 'user_message'
311
+ && typeof payload?.message === 'string' && payload.message.trim()) {
312
+ firstPrompt = payload.message.trim().slice(0, 200);
313
+ }
314
+ }
315
+ id ??= fallbackId;
316
+ return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
317
+ }, st => ({ meta: { id: fallbackId, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: null }));
148
318
  }
149
319
  async function codexSessionFiles(dir) {
150
320
  let entries;
@@ -164,10 +334,30 @@ async function codexSessionFiles(dir) {
164
334
  }
165
335
  return out;
166
336
  }
167
- async function scanCodexSessionsDir(sessionsDir, scope) {
337
+ async function claudeSessionFile(projectsDir, id) {
338
+ let projects;
339
+ try {
340
+ projects = await readdir(projectsDir, { withFileTypes: true });
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ for (const project of projects) {
346
+ if (!project.isDirectory())
347
+ continue;
348
+ const file = join(projectsDir, project.name, `${id}.jsonl`);
349
+ try {
350
+ if ((await stat(file)).isFile())
351
+ return file;
352
+ }
353
+ catch { /* keep looking */ }
354
+ }
355
+ return null;
356
+ }
357
+ async function scanCodexSessionsDir(sessionsDir, scope, budget) {
168
358
  const byProject = new Map();
169
359
  for (const file of await codexSessionFiles(sessionsDir)) {
170
- const parsed = await parseCodexSession(file);
360
+ const parsed = await parseCodexSession(file, budget);
171
361
  if (!parsed)
172
362
  continue;
173
363
  const project = parsed.cwd ?? '(unknown project)';
@@ -181,9 +371,10 @@ async function scanCodexSessionsDir(sessionsDir, scope) {
181
371
  }));
182
372
  }
183
373
  export async function scanSessions(opts) {
374
+ const budget = { bytesLeft: MAX_TOTAL_SCAN_BYTES };
184
375
  const out = [];
185
- out.push(...await scanProjectsDir(join(opts.sharedRoot, 'projects'), 'shared'));
186
- out.push(...await scanCodexSessionsDir(join(opts.sharedRoot, 'sessions'), 'shared'));
376
+ out.push(...await scanProjectsDir(join(opts.sharedRoot, 'projects'), 'shared', budget));
377
+ out.push(...await scanCodexSessionsDir(join(opts.sharedRoot, 'sessions'), 'shared', budget));
187
378
  for (const p of opts.profiles) {
188
379
  const agent = p.agent ?? 'claude';
189
380
  const sessionDir = join(p.dir, agent === 'codex' ? 'sessions' : 'projects');
@@ -195,8 +386,41 @@ export async function scanSessions(opts) {
195
386
  continue;
196
387
  }
197
388
  out.push(...agent === 'codex'
198
- ? await scanCodexSessionsDir(sessionDir, p.name)
199
- : await scanProjectsDir(sessionDir, p.name));
389
+ ? await scanCodexSessionsDir(sessionDir, p.name, budget)
390
+ : await scanProjectsDir(sessionDir, p.name, budget));
200
391
  }
201
392
  return out;
202
393
  }
394
+ /** Read visible conversation and tool activity for one known managed session. */
395
+ export async function readSessionTranscript(opts) {
396
+ if (!SAFE_SESSION_ID.test(opts.id))
397
+ return null;
398
+ let root;
399
+ if (opts.scope === 'shared')
400
+ root = join(opts.sharedRoot, opts.agent === 'codex' ? 'sessions' : 'projects');
401
+ else {
402
+ const profile = opts.profiles.find(p => p.name === opts.scope && (p.agent ?? 'claude') === opts.agent);
403
+ if (!profile)
404
+ return null;
405
+ root = join(profile.dir, opts.agent === 'codex' ? 'sessions' : 'projects');
406
+ try {
407
+ if ((await lstat(root)).isSymbolicLink())
408
+ return null;
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ }
414
+ if (opts.agent === 'claude') {
415
+ const file = await claudeSessionFile(root, opts.id);
416
+ return file ? parseClaudeTranscript(file, opts.scope) : null;
417
+ }
418
+ const files = await codexSessionFiles(root);
419
+ files.sort((a, b) => Number(!a.includes(opts.id)) - Number(!b.includes(opts.id)));
420
+ for (const file of files) {
421
+ const transcript = await parseCodexTranscript(file, opts.scope);
422
+ if (transcript?.id === opts.id)
423
+ return transcript;
424
+ }
425
+ return null;
426
+ }
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Core library for ccprofiles — discovery, manifest, secrets, apply, and LAN sync for Claude Code multi-account setups",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
8
14
  "files": [
9
- "dist"
15
+ "dist",
16
+ "README.md"
10
17
  ],
11
18
  "license": "MIT",
12
19
  "funding": {
@@ -34,5 +41,8 @@
34
41
  "smol-toml": "^1.7.0",
35
42
  "yaml": "^2.5.0",
36
43
  "zod": "^3.23.0"
44
+ },
45
+ "scripts": {
46
+ "prepack": "cd ../.. && npm run build"
37
47
  }
38
48
  }