@assethub/cli 0.1.29 → 0.1.31

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 (70) hide show
  1. package/README.md +282 -8
  2. package/dist/appEnv.d.ts +20 -0
  3. package/dist/appEnv.d.ts.map +1 -0
  4. package/dist/appEnv.js +62 -0
  5. package/dist/canvasGraphDownload.d.ts +11 -0
  6. package/dist/canvasGraphDownload.d.ts.map +1 -0
  7. package/dist/canvasGraphDownload.js +108 -0
  8. package/dist/execution.d.ts +5 -2
  9. package/dist/execution.d.ts.map +1 -1
  10. package/dist/execution.js +11 -12
  11. package/dist/hooks/command.d.ts +14 -0
  12. package/dist/hooks/command.d.ts.map +1 -0
  13. package/dist/hooks/command.js +178 -0
  14. package/dist/hooks/graph.d.ts +40 -0
  15. package/dist/hooks/graph.d.ts.map +1 -0
  16. package/dist/hooks/graph.js +267 -0
  17. package/dist/hooks/index.d.ts +7 -0
  18. package/dist/hooks/index.d.ts.map +1 -0
  19. package/dist/hooks/index.js +6 -0
  20. package/dist/hooks/install.d.ts +43 -0
  21. package/dist/hooks/install.d.ts.map +1 -0
  22. package/dist/hooks/install.js +174 -0
  23. package/dist/hooks/paths.d.ts +31 -0
  24. package/dist/hooks/paths.d.ts.map +1 -0
  25. package/dist/hooks/paths.js +101 -0
  26. package/dist/hooks/redact.d.ts +5 -0
  27. package/dist/hooks/redact.d.ts.map +1 -0
  28. package/dist/hooks/redact.js +67 -0
  29. package/dist/hooks/save.d.ts +60 -0
  30. package/dist/hooks/save.d.ts.map +1 -0
  31. package/dist/hooks/save.js +330 -0
  32. package/dist/hooks/transcript.d.ts +23 -0
  33. package/dist/hooks/transcript.d.ts.map +1 -0
  34. package/dist/hooks/transcript.js +74 -0
  35. package/dist/hooks/trim.d.ts +15 -0
  36. package/dist/hooks/trim.d.ts.map +1 -0
  37. package/dist/hooks/trim.js +108 -0
  38. package/dist/hooks/types.d.ts +56 -0
  39. package/dist/hooks/types.d.ts.map +1 -0
  40. package/dist/hooks/types.js +0 -0
  41. package/dist/hooks/upload.d.ts +52 -0
  42. package/dist/hooks/upload.d.ts.map +1 -0
  43. package/dist/hooks/upload.js +252 -0
  44. package/dist/index.d.ts +12 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1579 -166
  47. package/dist/nodeMeshBatch.d.ts.map +1 -1
  48. package/dist/nodeMeshBatch.js +5 -4
  49. package/dist/productionBatch.d.ts +77 -0
  50. package/dist/productionBatch.d.ts.map +1 -0
  51. package/dist/productionBatch.js +204 -0
  52. package/dist/runProgress.d.ts +4 -0
  53. package/dist/runProgress.d.ts.map +1 -0
  54. package/dist/runProgress.js +16 -0
  55. package/dist/runsUpload/controlEndpoints.d.ts +1 -1
  56. package/dist/runsUpload/controlEndpoints.d.ts.map +1 -1
  57. package/dist/runsUpload/controlEndpoints.js +4 -2
  58. package/dist/runsUpload/uploadRun.d.ts +7 -1
  59. package/dist/runsUpload/uploadRun.d.ts.map +1 -1
  60. package/dist/runsUpload/uploadRun.js +7 -4
  61. package/dist/setupCommand.d.ts +127 -0
  62. package/dist/setupCommand.d.ts.map +1 -0
  63. package/dist/setupCommand.js +534 -0
  64. package/dist/setupHooksBridge.d.ts +8 -0
  65. package/dist/setupHooksBridge.d.ts.map +1 -0
  66. package/dist/setupHooksBridge.js +4 -0
  67. package/dist/update.d.ts +74 -0
  68. package/dist/update.d.ts.map +1 -0
  69. package/dist/update.js +205 -0
  70. package/package.json +2 -2
@@ -0,0 +1,330 @@
1
+ // The hook body: copy + redact the transcript, collect images, prune old
2
+ // sessions, and on SessionEnd start the upload in a detached process. Never throws.
3
+ import { spawn } from 'node:child_process';
4
+ import { chmod, copyFile, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
5
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path';
6
+ import { PRIVATE_FILE_MODE, ensurePrivateDir, listSessionDirs, logError, readMeta, sessionsRoot, updateMeta, writePrivateFileAtomic, } from './paths.js';
7
+ import { redactJsonl } from './redact.js';
8
+ import { TRANSCRIPT_FORMAT, trimTranscript } from './trim.js';
9
+ import { extractImagePaths } from './transcript.js';
10
+ export const MAX_IMAGES = 50;
11
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
12
+ export const DEFAULT_RETENTION_DAYS = 30;
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const MIME_BY_EXT = {
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.jpeg': 'image/jpeg',
18
+ '.webp': 'image/webp',
19
+ };
20
+ const spawnCli = (args, profile) => {
21
+ const script = process.argv[1];
22
+ if (!script)
23
+ return;
24
+ const argv = [script, ...args];
25
+ if (profile)
26
+ argv.push('--profile', profile);
27
+ const child = spawn(process.execPath, argv, { detached: true, stdio: 'ignore' });
28
+ child.on('error', () => { });
29
+ child.unref();
30
+ };
31
+ /** SessionEnd: upload this session, then retry up to SWEEP_LIMIT older pending ones. */
32
+ export const spawnDetachedUpload = (sessionDir, profile) => spawnCli(['hooks', 'upload', '--session', sessionDir, '--auto', '--sweep', String(SWEEP_LIMIT)], profile);
33
+ /** SessionStart: retry up to SWEEP_LIMIT pending sessions. */
34
+ export const spawnDetachedSweep = (profile) => spawnCli(['hooks', 'upload', '--pending', '--limit', String(SWEEP_LIMIT)], profile);
35
+ export const retentionDays = (env) => {
36
+ const raw = env.ASSETHUB_SESSION_RETENTION_DAYS?.trim();
37
+ if (!raw)
38
+ return DEFAULT_RETENTION_DAYS;
39
+ if (raw === 'off' || raw === '0')
40
+ return null;
41
+ const days = Number(raw);
42
+ return Number.isFinite(days) && days > 0 ? days : DEFAULT_RETENTION_DAYS;
43
+ };
44
+ /** Delete saved sessions last written more than `days` ago, whatever their upload state. */
45
+ export const pruneSessions = async (root, now, days, keep) => {
46
+ let removed = 0;
47
+ let names;
48
+ try {
49
+ names = await readdir(root);
50
+ }
51
+ catch {
52
+ return 0;
53
+ }
54
+ for (const name of names) {
55
+ const dir = join(root, name);
56
+ if (dir === keep)
57
+ continue;
58
+ try {
59
+ const info = await stat(dir);
60
+ if (!info.isDirectory())
61
+ continue;
62
+ const meta = await readMeta(dir);
63
+ const savedAt = meta?.savedAt ? new Date(meta.savedAt).getTime() : info.mtimeMs;
64
+ if (now.getTime() - savedAt <= days * DAY_MS)
65
+ continue;
66
+ await rm(dir, { recursive: true, force: true });
67
+ removed += 1;
68
+ }
69
+ catch {
70
+ // leave anything we cannot read
71
+ }
72
+ }
73
+ return removed;
74
+ };
75
+ const optedOut = async (cwd, env) => {
76
+ if (env.ASSETHUB_SESSION_SAVE === 'off')
77
+ return true;
78
+ let dir = resolve(cwd);
79
+ for (;;) {
80
+ try {
81
+ await stat(join(dir, '.assethub', 'no-session-save'));
82
+ return true;
83
+ }
84
+ catch {
85
+ // keep walking
86
+ }
87
+ const parent = dirname(dir);
88
+ if (parent === dir)
89
+ return false;
90
+ dir = parent;
91
+ }
92
+ };
93
+ const readCanvasId = async (cwd, env) => {
94
+ if (env.ASSETHUB_CANVAS?.trim())
95
+ return env.ASSETHUB_CANVAS.trim();
96
+ try {
97
+ return (await readFile(join(cwd, '.assethub', 'canvas'), 'utf8')).trim() || undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ };
103
+ const safeId = (value) => value.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 100);
104
+ // `<yyyy-mm-dd>_<id>`: match the id exactly, so session `b` never adopts the
105
+ // directory of session `a_b`.
106
+ const SESSION_DIR_DATE = /^\d{4}-\d{2}-\d{2}_/;
107
+ const findSessionDir = async (root, id, date) => {
108
+ try {
109
+ const existing = (await readdir(root)).find(name => SESSION_DIR_DATE.test(name) && name.slice(11) === id);
110
+ if (existing)
111
+ return join(root, existing);
112
+ }
113
+ catch {
114
+ // root does not exist yet
115
+ }
116
+ return join(root, `${date}_${id}`);
117
+ };
118
+ const collectImages = async (sessionDir, transcript, cwd) => {
119
+ const images = [];
120
+ let realCwd;
121
+ try {
122
+ realCwd = await realpath(cwd);
123
+ }
124
+ catch {
125
+ return images;
126
+ }
127
+ for (const original of extractImagePaths(transcript)) {
128
+ if (images.length >= MAX_IMAGES)
129
+ break;
130
+ try {
131
+ const real = await realpath(original);
132
+ if (real !== realCwd && !real.startsWith(realCwd + sep))
133
+ continue;
134
+ const info = await stat(real);
135
+ if (!info.isFile() || info.size > MAX_IMAGE_BYTES)
136
+ continue;
137
+ const file = `${String(images.length).padStart(3, '0')}-${safeId(basename(real))}`;
138
+ await ensurePrivateDir(join(sessionDir, 'images'));
139
+ await copyFile(real, join(sessionDir, 'images', file));
140
+ await chmod(join(sessionDir, 'images', file), PRIVATE_FILE_MODE);
141
+ images.push({
142
+ original,
143
+ file,
144
+ size: info.size,
145
+ mime: MIME_BY_EXT[extname(real).toLowerCase()] ?? 'application/octet-stream',
146
+ });
147
+ }
148
+ catch {
149
+ // missing or unreadable: skip
150
+ }
151
+ }
152
+ return images;
153
+ };
154
+ /** A session with no hook for this long, and never ended, was abandoned (crash, killed terminal). */
155
+ export const ABANDONED_AFTER_MS = 2 * 60 * 60 * 1000;
156
+ /** Sessions retried in the background per SessionStart / SessionEnd. */
157
+ export const SWEEP_LIMIT = 3;
158
+ /** Events that copy and redact the transcript now; the rest only mark it changed. */
159
+ const FULL_SAVE_EVENTS = new Set(['SessionEnd', 'Manual']);
160
+ /**
161
+ * Copy + redact the transcript Claude Code keeps at `meta.transcriptPath`, and
162
+ * collect its images. Runs once per SessionEnd and before an upload, never per
163
+ * turn. A copy shorter than the saved one is an older read (a transcript only
164
+ * grows) and never replaces it.
165
+ */
166
+ export const materializeSession = async (sessionDir, options = {}) => {
167
+ const meta = await readMeta(sessionDir);
168
+ if (!meta)
169
+ return null;
170
+ const source = options.transcriptPath ?? meta.transcriptPath;
171
+ const target = join(sessionDir, 'transcript.jsonl');
172
+ // A copy an older CLI saved holds the whole transcript: it is trimmed once,
173
+ // even though that makes it shorter.
174
+ const legacy = meta.transcriptFormat !== TRANSCRIPT_FORMAT;
175
+ let raw;
176
+ let fromLive = source != null;
177
+ try {
178
+ raw = await readFile(source ?? target, 'utf8');
179
+ }
180
+ catch {
181
+ // Claude Code already cleaned it up: keep our copy, trimmed if it is old.
182
+ if (!legacy || source == null)
183
+ return meta;
184
+ try {
185
+ raw = await readFile(target, 'utf8');
186
+ }
187
+ catch {
188
+ return meta;
189
+ }
190
+ fromLive = false;
191
+ }
192
+ // Only the conversation is kept, then recognised secrets in it are masked.
193
+ // Our own copy is already masked; trimming it again changes nothing.
194
+ const redacted = fromLive ? redactJsonl(trimTranscript(raw)) : trimTranscript(raw);
195
+ // Compare the live transcript before masking: masking rules can change
196
+ // between CLI versions, so a newer masked copy may be shorter.
197
+ const sourceBytes = Buffer.byteLength(raw, 'utf8');
198
+ if (!legacy) {
199
+ if (fromLive && meta.sourceBytes != null) {
200
+ if (sourceBytes < meta.sourceBytes)
201
+ return meta;
202
+ }
203
+ else {
204
+ const savedLength = await stat(target).then(info => info.size, () => -1);
205
+ if (Buffer.byteLength(redacted, 'utf8') < savedLength)
206
+ return meta;
207
+ }
208
+ }
209
+ await writePrivateFileAtomic(target, redacted);
210
+ const images = fromLive ? await collectImages(sessionDir, raw, meta.cwd) : undefined;
211
+ return updateMeta(sessionDir, meta, latest => ({
212
+ ...latest,
213
+ ...(images ? { images } : {}),
214
+ dirty: false,
215
+ ...(fromLive ? { sourceBytes } : {}),
216
+ transcriptFormat: TRANSCRIPT_FORMAT,
217
+ }));
218
+ };
219
+ /**
220
+ * On SessionStart: a session that never reached SessionEnd and has been quiet
221
+ * for ABANDONED_AFTER_MS is marked for upload, so a crash or a killed terminal
222
+ * does not lose it.
223
+ */
224
+ export const markAbandonedSessions = async (root, now, currentSessionId) => {
225
+ let marked = 0;
226
+ let names;
227
+ try {
228
+ names = await readdir(root);
229
+ }
230
+ catch {
231
+ return 0;
232
+ }
233
+ for (const name of names) {
234
+ const dir = join(root, name);
235
+ const meta = await readMeta(dir);
236
+ if (!meta || meta.status !== 'saved' || meta.sessionId === currentSessionId)
237
+ continue;
238
+ if (now.getTime() - new Date(meta.savedAt).getTime() < ABANDONED_AFTER_MS)
239
+ continue;
240
+ await updateMeta(dir, meta, latest => latest.status === 'saved' ? { ...latest, status: 'pending-upload', abandoned: true } : latest);
241
+ marked += 1;
242
+ }
243
+ return marked;
244
+ };
245
+ export const saveSession = async (options = {}) => {
246
+ const env = options.env ?? process.env;
247
+ try {
248
+ let input = options.input;
249
+ if (!input && options.stdin?.trim())
250
+ input = JSON.parse(options.stdin);
251
+ const event = input?.hook_event_name ?? 'unknown';
252
+ const now = (options.now ?? (() => new Date()))();
253
+ const root = sessionsRoot(options.home);
254
+ const uploadOn = env.ASSETHUB_SESSION_UPLOAD !== 'off';
255
+ // SessionStart: pick up what earlier sessions left behind, record nothing.
256
+ if (event === 'SessionStart') {
257
+ const cwd = input?.cwd ?? process.cwd();
258
+ if (await optedOut(cwd, env))
259
+ return { sessionDir: '', skipped: 'opt-out', event };
260
+ const result = { sessionDir: '', event };
261
+ result.abandoned = await markAbandonedSessions(root, now, input?.session_id);
262
+ const days = retentionDays(env);
263
+ if (days != null)
264
+ result.pruned = await pruneSessions(root, now, days, '');
265
+ if (uploadOn && (await listSessionDirs(options.home)).length > 0) {
266
+ ;
267
+ (options.startSweep ?? spawnDetachedSweep)(options.upload?.profile);
268
+ result.uploadStarted = true;
269
+ }
270
+ return result;
271
+ }
272
+ if (!input?.session_id || !input.transcript_path)
273
+ return { sessionDir: '', skipped: 'no-input', event };
274
+ const cwd = input.cwd ?? process.cwd();
275
+ if (await optedOut(cwd, env))
276
+ return { sessionDir: '', skipped: 'opt-out', event };
277
+ const id = safeId(input.session_id);
278
+ const sessionDir = await findSessionDir(root, id, now.toISOString().slice(0, 10));
279
+ await ensurePrivateDir(root);
280
+ await ensurePrivateDir(sessionDir);
281
+ const canvasId = await readCanvasId(cwd, env);
282
+ const sessionId = input.session_id;
283
+ const transcriptPath = input.transcript_path;
284
+ const full = FULL_SAVE_EVENTS.has(event);
285
+ const fresh = {
286
+ sessionId,
287
+ cwd,
288
+ client: 'claude',
289
+ lastEvent: event,
290
+ savedAt: now.toISOString(),
291
+ status: 'saved',
292
+ images: [],
293
+ };
294
+ // Stop and PreCompact only record that the session moved on: cheap, and
295
+ // nothing to race. Decide from the meta as it is now.
296
+ await updateMeta(sessionDir, fresh, latest => ({
297
+ ...latest,
298
+ sessionId,
299
+ cwd,
300
+ client: 'claude',
301
+ transcriptPath,
302
+ lastEvent: event,
303
+ savedAt: now.toISOString(),
304
+ canvasId: canvasId ?? latest.canvasId,
305
+ dirty: true,
306
+ // An already-uploaded session that grew needs a new revision.
307
+ status: event === 'SessionEnd' || latest.status === 'uploaded'
308
+ ? 'pending-upload'
309
+ : (latest.status ?? 'saved'),
310
+ }));
311
+ if (full)
312
+ await materializeSession(sessionDir, { env, transcriptPath });
313
+ const result = { sessionDir, event };
314
+ if (event === 'SessionEnd') {
315
+ const days = retentionDays(env);
316
+ if (days != null)
317
+ result.pruned = await pruneSessions(root, now, days, sessionDir);
318
+ if (uploadOn) {
319
+ ;
320
+ (options.startUpload ?? spawnDetachedUpload)(sessionDir, options.upload?.profile);
321
+ result.uploadStarted = true;
322
+ }
323
+ }
324
+ return result;
325
+ }
326
+ catch (error) {
327
+ await logError(options.home, error);
328
+ return { sessionDir: '' };
329
+ }
330
+ };
@@ -0,0 +1,23 @@
1
+ export type TranscriptItem = {
2
+ kind: 'prompt';
3
+ text: string;
4
+ timestamp?: string;
5
+ } | {
6
+ kind: 'assistant';
7
+ text: string;
8
+ timestamp?: string;
9
+ } | {
10
+ kind: 'tool';
11
+ toolUseId: string;
12
+ toolName: string;
13
+ input: unknown;
14
+ timestamp?: string;
15
+ };
16
+ export type ParsedTranscript = {
17
+ items: TranscriptItem[];
18
+ /** tool_use id -> absolute image paths seen in its tool_result. */
19
+ resultImages: Map<string, string[]>;
20
+ };
21
+ export declare const extractImagePaths: (text: string) => string[];
22
+ export declare const parseTranscript: (raw: string) => ParsedTranscript;
23
+ //# sourceMappingURL=transcript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../src/hooks/transcript.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GACtB;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GAClD;IAAC,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,GACrD;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAE3F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,mEAAmE;IACnE,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;CACpC,CAAA;AAID,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,KAAG,MAAM,EAGtD,CAAA;AAgBD,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,gBAiD7C,CAAA"}
@@ -0,0 +1,74 @@
1
+ // Parse a Claude Code transcript (JSONL) into the flat sequence the session
2
+ // graph is built from.
3
+ const IMAGE_PATH_RE = /(?<![\w.~-])(?:\/[^\s"'\\<>|:*?`()[\]{}]+)+\.(?:png|jpe?g|webp)\b/gi;
4
+ export const extractImagePaths = (text) => {
5
+ const found = text.match(IMAGE_PATH_RE) ?? [];
6
+ return [...new Set(found)];
7
+ };
8
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
9
+ const textOf = (content) => {
10
+ if (typeof content === 'string')
11
+ return content;
12
+ if (!Array.isArray(content))
13
+ return '';
14
+ return content
15
+ .map(block => isObject(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : '')
16
+ .filter(Boolean)
17
+ .join('\n');
18
+ };
19
+ export const parseTranscript = (raw) => {
20
+ const items = [];
21
+ const resultImages = new Map();
22
+ for (const line of raw.split('\n')) {
23
+ if (!line.trim())
24
+ continue;
25
+ let entry;
26
+ try {
27
+ entry = JSON.parse(line);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ if (!isObject(entry) || !isObject(entry.message))
33
+ continue;
34
+ const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
35
+ const content = entry.message.content;
36
+ if (entry.type === 'user') {
37
+ if (entry.isMeta === true)
38
+ continue;
39
+ if (Array.isArray(content)) {
40
+ for (const block of content) {
41
+ if (isObject(block) &&
42
+ block.type === 'tool_result' &&
43
+ typeof block.tool_use_id === 'string') {
44
+ const paths = extractImagePaths(JSON.stringify(block.content ?? ''));
45
+ if (paths.length)
46
+ resultImages.set(block.tool_use_id, paths);
47
+ }
48
+ }
49
+ }
50
+ const text = textOf(content).trim();
51
+ if (text)
52
+ items.push({ kind: 'prompt', text, timestamp });
53
+ }
54
+ else if (entry.type === 'assistant') {
55
+ const text = textOf(content).trim();
56
+ if (text)
57
+ items.push({ kind: 'assistant', text, timestamp });
58
+ if (Array.isArray(content)) {
59
+ for (const block of content) {
60
+ if (isObject(block) && block.type === 'tool_use' && typeof block.id === 'string') {
61
+ items.push({
62
+ kind: 'tool',
63
+ toolUseId: block.id,
64
+ toolName: typeof block.name === 'string' ? block.name : 'unknown',
65
+ input: block.input ?? {},
66
+ timestamp,
67
+ });
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ return { items, resultImages };
74
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Recorded in a session's meta once its copy holds only the conversation. A
3
+ * copy without it was saved by an older CLI and is trimmed before upload.
4
+ */
5
+ export declare const TRANSCRIPT_FORMAT = "conversation-v1";
6
+ type Json = Record<string, unknown>;
7
+ /** One transcript entry reduced to what the conversation needs, or undefined to drop it. */
8
+ export declare const trimTranscriptEntry: (entry: unknown) => Json | undefined;
9
+ /**
10
+ * Trim a JSONL transcript. A file that is not a Claude Code transcript (for
11
+ * example a Codex rollout saved by hand) is returned unchanged.
12
+ */
13
+ export declare const trimTranscript: (raw: string) => string;
14
+ export {};
15
+ //# sourceMappingURL=trim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trim.d.ts","sourceRoot":"","sources":["../../src/hooks/trim.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,eAAO,MAAM,iBAAiB,oBAAoB,CAAA;AAElD,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAuDnC,4FAA4F;AAC5F,eAAO,MAAM,mBAAmB,GAAI,OAAO,OAAO,KAAG,IAAI,GAAG,SAqB3D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAc5C,CAAA"}
@@ -0,0 +1,108 @@
1
+ // Keep only the conversation from a Claude Code transcript before it is saved
2
+ // or uploaded: the artist's prompts, the agent's replies, its tool calls and
3
+ // their results, and compaction summaries. Everything else Claude Code writes
4
+ // to the file (skill, tool and agent listings, the system prompt, hook
5
+ // context, the environment, the signed-in email, file-history snapshots) is
6
+ // dropped. So is per-line bookkeeping such as the working directory, git
7
+ // branch, token usage and thinking blocks. Image pixels are saved separately,
8
+ // so an inline image keeps only its media type.
9
+ /**
10
+ * Recorded in a session's meta once its copy holds only the conversation. A
11
+ * copy without it was saved by an older CLI and is trimmed before upload.
12
+ */
13
+ export const TRANSCRIPT_FORMAT = 'conversation-v1';
14
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
15
+ /** Line types a Claude Code transcript uses; any of them marks the file as one. */
16
+ const CLAUDE_LINE_TYPES = new Set([
17
+ 'user',
18
+ 'assistant',
19
+ 'summary',
20
+ 'system',
21
+ 'attachment',
22
+ 'queue-operation',
23
+ 'file-history-snapshot',
24
+ 'last-prompt',
25
+ ]);
26
+ // Claude Code can append context (for example CLAUDE.md) inside a message.
27
+ const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>\s*/g;
28
+ const cleanText = (text) => text.replace(SYSTEM_REMINDER, '');
29
+ const cleanBlock = (block) => {
30
+ if (!isObject(block))
31
+ return undefined;
32
+ switch (block.type) {
33
+ case 'text': {
34
+ const text = cleanText(typeof block.text === 'string' ? block.text : '');
35
+ return text.trim() ? { type: 'text', text } : undefined;
36
+ }
37
+ case 'tool_use':
38
+ return { type: 'tool_use', id: block.id, name: block.name, input: block.input ?? {} };
39
+ case 'tool_result':
40
+ return {
41
+ type: 'tool_result',
42
+ tool_use_id: block.tool_use_id,
43
+ ...(block.is_error === true ? { is_error: true } : {}),
44
+ content: cleanContent(block.content) ?? '',
45
+ };
46
+ case 'image': {
47
+ const source = isObject(block.source) ? block.source : {};
48
+ return { type: 'image', ...(typeof source.media_type === 'string' ? { media_type: source.media_type } : {}) };
49
+ }
50
+ default:
51
+ // thinking, redacted_thinking and anything else that is not conversation
52
+ return undefined;
53
+ }
54
+ };
55
+ const cleanContent = (content) => {
56
+ if (typeof content === 'string')
57
+ return cleanText(content);
58
+ if (!Array.isArray(content))
59
+ return undefined;
60
+ return content.map(cleanBlock).filter((block) => block !== undefined);
61
+ };
62
+ const isEmpty = (content) => content === undefined || (typeof content === 'string' ? !content.trim() : content.length === 0);
63
+ /** One transcript entry reduced to what the conversation needs, or undefined to drop it. */
64
+ export const trimTranscriptEntry = (entry) => {
65
+ if (!isObject(entry))
66
+ return undefined;
67
+ if (entry.type === 'summary' && typeof entry.summary === 'string')
68
+ return { type: 'summary', summary: entry.summary, ...(typeof entry.leafUuid === 'string' ? { leafUuid: entry.leafUuid } : {}) };
69
+ if ((entry.type !== 'user' && entry.type !== 'assistant') || entry.isMeta === true || !isObject(entry.message))
70
+ return undefined;
71
+ const content = cleanContent(entry.message.content);
72
+ if (isEmpty(content))
73
+ return undefined;
74
+ const message = entry.message;
75
+ return {
76
+ type: entry.type,
77
+ ...(typeof entry.uuid === 'string' ? { uuid: entry.uuid } : {}),
78
+ ...('parentUuid' in entry ? { parentUuid: entry.parentUuid ?? null } : {}),
79
+ ...(typeof entry.isSidechain === 'boolean' ? { isSidechain: entry.isSidechain } : {}),
80
+ ...(typeof entry.timestamp === 'string' ? { timestamp: entry.timestamp } : {}),
81
+ message: {
82
+ role: typeof message.role === 'string' ? message.role : entry.type,
83
+ ...(typeof message.model === 'string' ? { model: message.model } : {}),
84
+ content,
85
+ },
86
+ };
87
+ };
88
+ /**
89
+ * Trim a JSONL transcript. A file that is not a Claude Code transcript (for
90
+ * example a Codex rollout saved by hand) is returned unchanged.
91
+ */
92
+ export const trimTranscript = (raw) => {
93
+ const entries = raw
94
+ .split('\n')
95
+ .filter(line => line.trim())
96
+ .map(line => {
97
+ try {
98
+ return JSON.parse(line);
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ });
104
+ if (!entries.some(entry => isObject(entry) && CLAUDE_LINE_TYPES.has(String(entry.type))))
105
+ return raw;
106
+ const kept = entries.map(trimTranscriptEntry).filter((entry) => entry !== undefined);
107
+ return kept.map(entry => `${JSON.stringify(entry)}\n`).join('');
108
+ };
@@ -0,0 +1,56 @@
1
+ export type HookClient = 'claude' | 'codex';
2
+ export type HookInput = {
3
+ session_id?: string;
4
+ transcript_path?: string;
5
+ cwd?: string;
6
+ hook_event_name?: string;
7
+ };
8
+ export type SessionStatus = 'saved' | 'pending-upload' | 'uploaded';
9
+ export type SessionImage = {
10
+ original: string;
11
+ file: string;
12
+ size: number;
13
+ mime: string;
14
+ };
15
+ export type SessionMeta = {
16
+ sessionId: string;
17
+ cwd: string;
18
+ client: 'claude';
19
+ lastEvent: string;
20
+ savedAt: string;
21
+ canvasId?: string;
22
+ status: SessionStatus;
23
+ images: SessionImage[];
24
+ /** Highest rev successfully uploaded; the next upload uses uploadedRev + 1. */
25
+ uploadedRev?: number;
26
+ /**
27
+ * Highest rev ever sent, success or not. A snapshot whose response was lost
28
+ * may have been published, so the next upload goes past this one too.
29
+ */
30
+ attemptedRev?: number;
31
+ graphId?: string;
32
+ reason?: string;
33
+ lastUploadAttemptAt?: string;
34
+ /** Where Claude Code keeps the live transcript; copied and redacted on SessionEnd or upload. */
35
+ transcriptPath?: string;
36
+ /** The live transcript moved on since the local copy was made. */
37
+ dirty?: boolean;
38
+ /** Never reached SessionEnd; picked up by a later SessionStart. */
39
+ abandoned?: boolean;
40
+ /** How far the graph is compacted after uploads failed for size or time (0 = none). */
41
+ compactLevel?: number;
42
+ /** Failed uploads in a row; spaces out the automatic retries. */
43
+ failedAttempts?: number;
44
+ /** Size of the live transcript the local copy was made from (before masking). */
45
+ sourceBytes?: number;
46
+ /** `conversation-v1` once the local copy is trimmed; absent for copies an older CLI saved. */
47
+ transcriptFormat?: string;
48
+ };
49
+ export type UploadOutcome = {
50
+ ok: boolean;
51
+ graphId?: string;
52
+ reason?: string;
53
+ /** Skipped without a request because an earlier failure is still being waited out. */
54
+ throttled?: true;
55
+ };
56
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/hooks/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE3C,MAAM,MAAM,SAAS,GAAG;IACtB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,gBAAgB,GAAG,UAAU,CAAA;AAEnE,MAAM,MAAM,YAAY,GAAG;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAAA;AAEvF,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,QAAQ,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,aAAa,CAAA;IACrB,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,gGAAgG;IAChG,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,uFAAuF;IACvF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iFAAiF;IACjF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,sFAAsF;IACtF,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB,CAAA"}
File without changes
@@ -0,0 +1,52 @@
1
+ import type { UploadOutcome } from './types.js';
2
+ export declare const CODING_AGENT_SESSION_API_PREFIX = "/api/v2/coding-agent-sessions";
3
+ export declare const UPLOAD_TIMEOUT_MS = 20000;
4
+ export type UploadAuth = {
5
+ apiKey: string;
6
+ baseUrl: string;
7
+ workspaceId?: string;
8
+ };
9
+ export type UploadOptions = {
10
+ profile?: string;
11
+ home?: string;
12
+ env?: NodeJS.ProcessEnv;
13
+ fetchImpl?: typeof fetch;
14
+ auth?: UploadAuth;
15
+ now?: () => Date;
16
+ /** Ignore the once-a-day throttle after a `no-upload-access` answer. */
17
+ force?: boolean;
18
+ timeoutMs?: number;
19
+ };
20
+ /**
21
+ * Resolve the CLI profile's key + base URL from ~/.assethub/config.json.
22
+ * src/index.ts's resolveAuth is module-private, so this mirrors its precedence
23
+ * for the non-interactive case (stored profile, then ASSETHUB_API_KEY).
24
+ */
25
+ export declare const resolveUploadAuth: (options: Pick<UploadOptions, "profile" | "home" | "env">) => Promise<UploadAuth | null>;
26
+ /** Automatic retries after an ordinary failure wait 1h, 2h, 4h … up to a day. */
27
+ export declare const retryDelayMs: (failedAttempts: number) => number;
28
+ /**
29
+ * Whether this login may upload coding-agent sessions (internal accounts for
30
+ * now). The register route answers 404 to anyone it does not let in before it
31
+ * reads the body; for everyone else this empty body fails validation, so
32
+ * nothing is written. Any other answer, or no answer, counts as "no".
33
+ */
34
+ export declare const canUploadSessions: (auth: UploadAuth, fetchImpl?: typeof fetch) => Promise<boolean>;
35
+ /**
36
+ * One upload per session at a time: the SessionEnd upload and a background
37
+ * retry would otherwise rebuild the same graph folder under each other.
38
+ */
39
+ export declare const uploadSession: (sessionDir: string, options?: UploadOptions) => Promise<UploadOutcome>;
40
+ /**
41
+ * Upload pending sessions, newest first. `limit` caps how many are attempted
42
+ * (sessions waiting out a retry delay do not count); `exclude` skips one that
43
+ * the caller already handled.
44
+ */
45
+ export declare const uploadPending: (options?: UploadOptions & {
46
+ limit?: number;
47
+ exclude?: string;
48
+ }) => Promise<{
49
+ session: string;
50
+ outcome: UploadOutcome;
51
+ }[]>;
52
+ //# sourceMappingURL=upload.d.ts.map