ccprofiles-core 0.5.0 → 0.6.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.
@@ -13,6 +13,20 @@ export interface ProjectSessions {
13
13
  project: string;
14
14
  sessions: SessionMeta[];
15
15
  }
16
+ export interface TranscriptEntry {
17
+ id: string;
18
+ role: 'user' | 'assistant' | 'tool';
19
+ text: string;
20
+ label: string | null;
21
+ timestamp: string | null;
22
+ }
23
+ export interface SessionTranscript {
24
+ id: string;
25
+ agent: 'claude' | 'codex';
26
+ scope: string;
27
+ project: string;
28
+ messages: TranscriptEntry[];
29
+ }
16
30
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
17
31
  export declare function decodeProjectDir(name: string): string;
18
32
  export declare function scanSessions(opts: {
@@ -23,3 +37,15 @@ export declare function scanSessions(opts: {
23
37
  agent?: 'claude' | 'codex';
24
38
  }[];
25
39
  }): Promise<ProjectSessions[]>;
40
+ /** Read visible conversation and tool activity for one known managed session. */
41
+ export declare function readSessionTranscript(opts: {
42
+ sharedRoot: string;
43
+ profiles: {
44
+ name: string;
45
+ dir: string;
46
+ agent?: 'claude' | 'codex';
47
+ }[];
48
+ agent: 'claude' | 'codex';
49
+ scope: string;
50
+ id: string;
51
+ }): Promise<SessionTranscript | null>;
package/dist/sessions.js CHANGED
@@ -1,6 +1,8 @@
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;
4
6
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
5
7
  export function decodeProjectDir(name) {
6
8
  return name.replace(/^-/, '/').replace(/-/g, '/');
@@ -88,8 +90,133 @@ function messageText(content) {
88
90
  return content;
89
91
  if (!Array.isArray(content))
90
92
  return null;
91
- const item = content.find((x) => x?.type === 'input_text' || x?.type === 'text');
92
- return typeof item?.text === 'string' ? item.text : null;
93
+ const texts = content
94
+ .filter((x) => x?.type === 'input_text' || x?.type === 'output_text' || x?.type === 'text')
95
+ .map((x) => x.text)
96
+ .filter((x) => typeof x === 'string');
97
+ return texts.length ? texts.join('\n\n') : null;
98
+ }
99
+ function limited(text) {
100
+ return text.length <= MAX_ENTRY_CHARS ? text : `${text.slice(0, MAX_ENTRY_CHARS)}\n\n[entry truncated for display]`;
101
+ }
102
+ function toolText(value) {
103
+ if (typeof value === 'string')
104
+ return value;
105
+ try {
106
+ return JSON.stringify(value, null, 2);
107
+ }
108
+ catch {
109
+ return String(value);
110
+ }
111
+ }
112
+ function pushEntry(messages, role, text, label, timestamp) {
113
+ const clean = text?.trim();
114
+ if (!clean)
115
+ return;
116
+ if (role === 'user' && /^<(system-reminder|local-command-caveat|command-name)>/i.test(clean))
117
+ return;
118
+ const previous = messages.at(-1);
119
+ if (previous?.role === role && previous.text === clean)
120
+ return;
121
+ messages.push({
122
+ id: String(messages.length + 1), role, text: limited(clean), label,
123
+ timestamp: typeof timestamp === 'string' ? timestamp : null,
124
+ });
125
+ }
126
+ async function parseClaudeTranscript(file, scope) {
127
+ let raw;
128
+ try {
129
+ raw = await readFile(file, 'utf8');
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
135
+ const messages = [];
136
+ let project = '(unknown project)';
137
+ for (const line of raw.split('\n')) {
138
+ if (!line.trim())
139
+ continue;
140
+ let rec;
141
+ try {
142
+ rec = JSON.parse(line);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ if (project === '(unknown project)' && typeof rec.cwd === 'string')
148
+ project = rec.cwd;
149
+ const content = rec.message?.content;
150
+ if (rec.type === 'user') {
151
+ pushEntry(messages, 'user', messageText(content), null, rec.timestamp);
152
+ if (Array.isArray(content))
153
+ for (const block of content) {
154
+ if (block?.type === 'tool_result')
155
+ pushEntry(messages, 'tool', messageText(block.content) ?? toolText(block.content), 'Tool result', rec.timestamp);
156
+ }
157
+ }
158
+ else if (rec.type === 'assistant') {
159
+ pushEntry(messages, 'assistant', messageText(content), null, rec.timestamp);
160
+ if (Array.isArray(content))
161
+ for (const block of content) {
162
+ if (block?.type === 'tool_use')
163
+ pushEntry(messages, 'tool', toolText(block.input), typeof block.name === 'string' ? block.name : 'Tool call', rec.timestamp);
164
+ }
165
+ }
166
+ }
167
+ return { id, agent: 'claude', scope, project, messages };
168
+ }
169
+ async function parseCodexTranscript(file, scope) {
170
+ let raw;
171
+ try {
172
+ raw = await readFile(file, 'utf8');
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ const records = [];
178
+ for (const line of raw.split('\n')) {
179
+ if (!line.trim())
180
+ continue;
181
+ try {
182
+ records.push(JSON.parse(line));
183
+ }
184
+ catch { /* malformed line */ }
185
+ }
186
+ const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
187
+ 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;
188
+ let project = '(unknown project)';
189
+ const hasResponseMessages = records.some(rec => rec?.type === 'response_item' && rec?.payload?.type === 'message');
190
+ const messages = [];
191
+ for (const rec of records) {
192
+ const payload = rec?.payload;
193
+ if (rec?.type === 'session_meta') {
194
+ if (typeof payload?.id === 'string')
195
+ id = payload.id;
196
+ else if (typeof payload?.session_id === 'string')
197
+ id = payload.session_id;
198
+ if (typeof payload?.cwd === 'string')
199
+ project = payload.cwd;
200
+ continue;
201
+ }
202
+ if (project === '(unknown project)' && rec?.type === 'turn_context' && typeof payload?.cwd === 'string')
203
+ project = payload.cwd;
204
+ if (rec?.type === 'response_item') {
205
+ if (payload?.type === 'message' && (payload.role === 'user' || payload.role === 'assistant'))
206
+ pushEntry(messages, payload.role, messageText(payload.content), null, rec.timestamp);
207
+ else if (payload?.type === 'function_call' || payload?.type === 'custom_tool_call' || payload?.type === 'local_shell_call')
208
+ pushEntry(messages, 'tool', toolText(payload.arguments ?? payload.input ?? payload), payload.name ?? payload.type, rec.timestamp);
209
+ else if (payload?.type === 'function_call_output' || payload?.type === 'custom_tool_call_output')
210
+ pushEntry(messages, 'tool', toolText(payload.output), 'Tool result', rec.timestamp);
211
+ }
212
+ else if (!hasResponseMessages && rec?.type === 'event_msg') {
213
+ if (payload?.type === 'user_message')
214
+ pushEntry(messages, 'user', payload.message, null, rec.timestamp);
215
+ else if (payload?.type === 'agent_message')
216
+ pushEntry(messages, 'assistant', payload.message, null, rec.timestamp);
217
+ }
218
+ }
219
+ return { id, agent: 'codex', scope, project, messages };
93
220
  }
94
221
  async function parseCodexSession(file) {
95
222
  let raw;
@@ -164,6 +291,26 @@ async function codexSessionFiles(dir) {
164
291
  }
165
292
  return out;
166
293
  }
294
+ async function claudeSessionFile(projectsDir, id) {
295
+ let projects;
296
+ try {
297
+ projects = await readdir(projectsDir, { withFileTypes: true });
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ for (const project of projects) {
303
+ if (!project.isDirectory())
304
+ continue;
305
+ const file = join(projectsDir, project.name, `${id}.jsonl`);
306
+ try {
307
+ if ((await stat(file)).isFile())
308
+ return file;
309
+ }
310
+ catch { /* keep looking */ }
311
+ }
312
+ return null;
313
+ }
167
314
  async function scanCodexSessionsDir(sessionsDir, scope) {
168
315
  const byProject = new Map();
169
316
  for (const file of await codexSessionFiles(sessionsDir)) {
@@ -200,3 +347,36 @@ export async function scanSessions(opts) {
200
347
  }
201
348
  return out;
202
349
  }
350
+ /** Read visible conversation and tool activity for one known managed session. */
351
+ export async function readSessionTranscript(opts) {
352
+ if (!SAFE_SESSION_ID.test(opts.id))
353
+ return null;
354
+ let root;
355
+ if (opts.scope === 'shared')
356
+ root = join(opts.sharedRoot, opts.agent === 'codex' ? 'sessions' : 'projects');
357
+ else {
358
+ const profile = opts.profiles.find(p => p.name === opts.scope && (p.agent ?? 'claude') === opts.agent);
359
+ if (!profile)
360
+ return null;
361
+ root = join(profile.dir, opts.agent === 'codex' ? 'sessions' : 'projects');
362
+ try {
363
+ if ((await lstat(root)).isSymbolicLink())
364
+ return null;
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ }
370
+ if (opts.agent === 'claude') {
371
+ const file = await claudeSessionFile(root, opts.id);
372
+ return file ? parseClaudeTranscript(file, opts.scope) : null;
373
+ }
374
+ const files = await codexSessionFiles(root);
375
+ files.sort((a, b) => Number(!a.includes(opts.id)) - Number(!b.includes(opts.id)));
376
+ for (const file of files) {
377
+ const transcript = await parseCodexTranscript(file, opts.scope);
378
+ if (transcript?.id === opts.id)
379
+ return transcript;
380
+ }
381
+ return null;
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -9,6 +9,10 @@
9
9
  "dist"
10
10
  ],
11
11
  "license": "MIT",
12
+ "funding": {
13
+ "type": "individual",
14
+ "url": "https://ko-fi.com/teukufadh"
15
+ },
12
16
  "repository": {
13
17
  "type": "git",
14
18
  "url": "git+https://github.com/tmfadhlul/claude-code-profile.git",