@assethub/cli 0.1.30 → 0.1.32

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 +181 -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 +269 -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 +58 -0
  21. package/dist/hooks/install.d.ts.map +1 -0
  22. package/dist/hooks/install.js +286 -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 +62 -0
  30. package/dist/hooks/save.d.ts.map +1 -0
  31. package/dist/hooks/save.js +331 -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 +78 -0
  35. package/dist/hooks/trim.d.ts +25 -0
  36. package/dist/hooks/trim.d.ts.map +1 -0
  37. package/dist/hooks/trim.js +220 -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 +1569 -165
  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 +130 -0
  62. package/dist/setupCommand.d.ts.map +1 -0
  63. package/dist/setupCommand.js +569 -0
  64. package/dist/setupHooksBridge.d.ts +12 -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,220 @@
1
+ // Keep only the conversation from a Claude Code transcript (or a Codex rollout,
2
+ // converted to the same shape) before it is saved
3
+ // or uploaded: the artist's prompts, the agent's replies, its tool calls and
4
+ // their results, and compaction summaries. Everything else Claude Code writes
5
+ // to the file (skill, tool and agent listings, the system prompt, hook
6
+ // context, the environment, the signed-in email, file-history snapshots) is
7
+ // dropped. So is per-line bookkeeping such as the working directory, git
8
+ // branch, token usage and thinking blocks. Image pixels are saved separately,
9
+ // so an inline image keeps only its media type.
10
+ /**
11
+ * Recorded in a session's meta once its copy holds only the conversation. A
12
+ * copy without it was saved by an older CLI and is trimmed before upload.
13
+ */
14
+ export const TRANSCRIPT_FORMAT = 'conversation-v1';
15
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
16
+ /** Line types a Claude Code transcript uses; any of them marks the file as one. */
17
+ const CLAUDE_LINE_TYPES = new Set([
18
+ 'user',
19
+ 'assistant',
20
+ 'summary',
21
+ 'system',
22
+ 'attachment',
23
+ 'queue-operation',
24
+ 'file-history-snapshot',
25
+ 'last-prompt',
26
+ ]);
27
+ // Claude Code can append context (for example CLAUDE.md) inside a message.
28
+ const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>\s*/g;
29
+ const cleanText = (text) => text.replace(SYSTEM_REMINDER, '');
30
+ const cleanBlock = (block) => {
31
+ if (!isObject(block))
32
+ return undefined;
33
+ switch (block.type) {
34
+ case 'text': {
35
+ const text = cleanText(typeof block.text === 'string' ? block.text : '');
36
+ return text.trim() ? { type: 'text', text } : undefined;
37
+ }
38
+ case 'tool_use':
39
+ return { type: 'tool_use', id: block.id, name: block.name, input: block.input ?? {} };
40
+ case 'tool_result':
41
+ return {
42
+ type: 'tool_result',
43
+ tool_use_id: block.tool_use_id,
44
+ ...(block.is_error === true ? { is_error: true } : {}),
45
+ content: cleanContent(block.content) ?? '',
46
+ };
47
+ case 'image': {
48
+ const source = isObject(block.source) ? block.source : {};
49
+ return { type: 'image', ...(typeof source.media_type === 'string' ? { media_type: source.media_type } : {}) };
50
+ }
51
+ default:
52
+ // thinking, redacted_thinking and anything else that is not conversation
53
+ return undefined;
54
+ }
55
+ };
56
+ const cleanContent = (content) => {
57
+ if (typeof content === 'string')
58
+ return cleanText(content);
59
+ if (!Array.isArray(content))
60
+ return undefined;
61
+ return content.map(cleanBlock).filter((block) => block !== undefined);
62
+ };
63
+ const isEmpty = (content) => content === undefined || (typeof content === 'string' ? !content.trim() : content.length === 0);
64
+ /** One transcript entry reduced to what the conversation needs, or undefined to drop it. */
65
+ export const trimTranscriptEntry = (entry) => {
66
+ if (!isObject(entry))
67
+ return undefined;
68
+ if (entry.type === 'summary' && typeof entry.summary === 'string')
69
+ return { type: 'summary', summary: entry.summary, ...(typeof entry.leafUuid === 'string' ? { leafUuid: entry.leafUuid } : {}) };
70
+ if ((entry.type !== 'user' && entry.type !== 'assistant') || entry.isMeta === true || !isObject(entry.message))
71
+ return undefined;
72
+ const content = cleanContent(entry.message.content);
73
+ if (isEmpty(content))
74
+ return undefined;
75
+ const message = entry.message;
76
+ return {
77
+ type: entry.type,
78
+ ...(typeof entry.uuid === 'string' ? { uuid: entry.uuid } : {}),
79
+ ...('parentUuid' in entry ? { parentUuid: entry.parentUuid ?? null } : {}),
80
+ ...(typeof entry.isSidechain === 'boolean' ? { isSidechain: entry.isSidechain } : {}),
81
+ ...(typeof entry.timestamp === 'string' ? { timestamp: entry.timestamp } : {}),
82
+ message: {
83
+ role: typeof message.role === 'string' ? message.role : entry.type,
84
+ ...(typeof message.model === 'string' ? { model: message.model } : {}),
85
+ content,
86
+ },
87
+ };
88
+ };
89
+ /** Top-level record types of a Codex rollout (`~/.codex/sessions/.../rollout-*.jsonl`). */
90
+ const CODEX_LINE_TYPES = new Set(['session_meta', 'response_item', 'event_msg', 'turn_context', 'compacted']);
91
+ // Codex sends its context as user messages: AGENTS.md, and tagged blocks such
92
+ // as <environment_context> or <user_instructions>. They are not the artist's words.
93
+ const CODEX_CONTEXT_BLOCK = /<([A-Za-z][\w-]*(?:context|instructions|plugins))(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/g;
94
+ const codexUserText = (text) => /^\s*# AGENTS\.md instructions/.test(text) ? '' : cleanText(text.replace(CODEX_CONTEXT_BLOCK, ''));
95
+ const codexText = (content, blockType, clean) => (Array.isArray(content) ? content : [])
96
+ .map(block => (isObject(block) && block.type === blockType && typeof block.text === 'string' ? clean(block.text) : ''))
97
+ .filter(text => text.trim())
98
+ .join('\n');
99
+ // An exec result is JSON text with timing and token bookkeeping around the
100
+ // command's output; only the output is conversation.
101
+ const codexOutputText = (text) => {
102
+ if (text.trimStart().startsWith('{')) {
103
+ try {
104
+ const parsed = JSON.parse(text);
105
+ if (isObject(parsed) && typeof parsed.output === 'string')
106
+ return parsed.output;
107
+ }
108
+ catch {
109
+ // plain text that happens to start with a brace
110
+ }
111
+ }
112
+ return text;
113
+ };
114
+ /** Tool output: a string, `{output}`, or a list of `{type, text}` items (images are dropped). */
115
+ const codexToolOutput = (output) => {
116
+ if (typeof output === 'string')
117
+ return cleanText(codexOutputText(output));
118
+ if (Array.isArray(output))
119
+ return cleanText(output
120
+ .map(item => (isObject(item) && typeof item.text === 'string' ? codexOutputText(item.text) : ''))
121
+ .filter(Boolean)
122
+ .join('\n'));
123
+ if (isObject(output)) {
124
+ for (const key of ['output', 'content'])
125
+ if (typeof output[key] === 'string')
126
+ return cleanText(output[key]);
127
+ }
128
+ return output === undefined ? '' : JSON.stringify(output);
129
+ };
130
+ const codexArguments = (value) => {
131
+ if (typeof value !== 'string')
132
+ return value ?? {};
133
+ try {
134
+ return JSON.parse(value);
135
+ }
136
+ catch {
137
+ return { arguments: value };
138
+ }
139
+ };
140
+ const codexMessage = (type, timestamp, content) => ({
141
+ type,
142
+ ...(typeof timestamp === 'string' ? { timestamp } : {}),
143
+ message: { role: type, content },
144
+ });
145
+ /**
146
+ * One Codex rollout record as a conversation entry in the same shape as a
147
+ * trimmed Claude Code transcript, or undefined to drop it. Kept: the artist's
148
+ * prompts, the agent's replies, its tool calls and their output, and compaction
149
+ * summaries. Dropped: session metadata (base instructions, git, cwd), developer
150
+ * and context messages, reasoning, turn settings and the event stream, which
151
+ * repeats the messages.
152
+ */
153
+ export const codexEntry = (entry) => {
154
+ if (!isObject(entry) || !isObject(entry.payload))
155
+ return undefined;
156
+ const { payload, timestamp } = entry;
157
+ if (entry.type === 'compacted')
158
+ return typeof payload.message === 'string' && payload.message.trim() ? { type: 'summary', summary: payload.message } : undefined;
159
+ if (entry.type !== 'response_item')
160
+ return undefined;
161
+ switch (payload.type) {
162
+ case 'message': {
163
+ if (payload.role === 'user') {
164
+ const text = codexText(payload.content, 'input_text', codexUserText).trim();
165
+ return text ? codexMessage('user', timestamp, text) : undefined;
166
+ }
167
+ if (payload.role !== 'assistant')
168
+ return undefined;
169
+ const text = codexText(payload.content, 'output_text', cleanText);
170
+ return text.trim() ? codexMessage('assistant', timestamp, [{ type: 'text', text }]) : undefined;
171
+ }
172
+ case 'function_call':
173
+ case 'custom_tool_call':
174
+ case 'local_shell_call': {
175
+ const id = typeof payload.call_id === 'string' ? payload.call_id : payload.id;
176
+ if (typeof id !== 'string')
177
+ return undefined;
178
+ const name = typeof payload.name === 'string' ? payload.name : payload.type === 'local_shell_call' ? 'shell' : 'unknown';
179
+ const input = payload.type === 'function_call'
180
+ ? codexArguments(payload.arguments)
181
+ : payload.type === 'custom_tool_call'
182
+ ? { input: payload.input }
183
+ : (payload.action ?? {});
184
+ return codexMessage('assistant', timestamp, [{ type: 'tool_use', id, name, input }]);
185
+ }
186
+ case 'function_call_output':
187
+ case 'custom_tool_call_output': {
188
+ if (typeof payload.call_id !== 'string')
189
+ return undefined;
190
+ return codexMessage('user', timestamp, [{ type: 'tool_result', tool_use_id: payload.call_id, content: codexToolOutput(payload.output) }]);
191
+ }
192
+ default:
193
+ // reasoning, web search and anything else that is not conversation
194
+ return undefined;
195
+ }
196
+ };
197
+ /**
198
+ * Trim a JSONL transcript: a Claude Code transcript, or a Codex rollout turned
199
+ * into the same conversation shape. A file in any other format is returned
200
+ * unchanged.
201
+ */
202
+ export const trimTranscript = (raw) => {
203
+ const entries = raw
204
+ .split('\n')
205
+ .filter(line => line.trim())
206
+ .map(line => {
207
+ try {
208
+ return JSON.parse(line);
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ });
214
+ const isClaude = entries.some(entry => isObject(entry) && CLAUDE_LINE_TYPES.has(String(entry.type)));
215
+ const isCodex = !isClaude && entries.some(entry => isObject(entry) && CODEX_LINE_TYPES.has(String(entry.type)) && isObject(entry.payload));
216
+ if (!isClaude && !isCodex)
217
+ return raw;
218
+ const kept = entries.map(isCodex ? codexEntry : trimTranscriptEntry).filter((entry) => entry !== undefined);
219
+ return kept.map(entry => `${JSON.stringify(entry)}\n`).join('');
220
+ };
@@ -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: HookClient;
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,UAAU,CAAA;IAClB,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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/hooks/upload.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAc,aAAa,EAAC,MAAM,YAAY,CAAA;AAG1D,eAAO,MAAM,+BAA+B,kCAAkC,CAAA;AAQ9E,eAAO,MAAM,iBAAiB,QAAS,CAAA;AAEvC,MAAM,MAAM,UAAU,GAAG;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAC,CAAA;AAEhF,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,IAAI,CAAC,aAAa,EAAE,SAAS,GAAG,MAAM,GAAG,KAAK,CAAC,KACvD,OAAO,CAAC,UAAU,GAAG,IAAI,CA2B3B,CAAA;AAED,iFAAiF;AACjF,eAAO,MAAM,YAAY,GAAI,gBAAgB,MAAM,KAAG,MACmB,CAAA;AAmBzE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,UAAU,EAChB,YAAW,OAAO,KAAyC,KAC1D,OAAO,CAAC,OAAO,CAgBjB,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,aAAa,GACxB,YAAY,MAAM,EAClB,UAAS,aAAkB,KAC1B,OAAO,CAAC,aAAa,CASvB,CAAA;AAoHD;;;;GAIG;AACH,eAAO,MAAM,aAAa,GACxB,UAAS,aAAa,GAAG;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAM,KAC/D,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,aAAa,CAAA;CAAC,EAAE,CAkBrD,CAAA"}
@@ -0,0 +1,252 @@
1
+ // Upload saved sessions to AssetHub's coding-agent session store
2
+ // (`/api/v2/coding-agent-sessions`). It speaks the same `ag.registry.v1`
3
+ // contract as `runs upload`, so the plan and transport are shared; only the
4
+ // URL prefix and the extra `session` registration fields differ. Sessions are
5
+ // owned by, and readable only by, the key's user.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { basename, join } from 'node:path';
8
+ import { buildRunUploadPlan } from '../runsUpload/buildRunUpload.js';
9
+ import { controlRunUploadUrls } from '../runsUpload/controlEndpoints.js';
10
+ import { readGraphFolder } from '../runsUpload/graphFolder.js';
11
+ import { uploadRun } from '../runsUpload/uploadRun.js';
12
+ import { RunUploadError } from '../runsUpload/runUploadError.js';
13
+ import { MAX_COMPACT_LEVEL, buildSessionGraphFolder, sessionGraphId } from './graph.js';
14
+ import { acquireLock, listSessionDirs, readMeta, resolveHome, updateMeta } from './paths.js';
15
+ import { redactText } from './redact.js';
16
+ import { materializeSession } from './save.js';
17
+ import { TRANSCRIPT_FORMAT } from './trim.js';
18
+ const DEFAULT_BASE_URL = 'https://app.assethub.io';
19
+ export const CODING_AGENT_SESSION_API_PREFIX = '/api/v2/coding-agent-sessions';
20
+ /** `.assethub/canvas` holds a Workflow canvas id; only a positive integer is sent. */
21
+ const canvasIdOf = (value) => {
22
+ const id = Number(value?.trim());
23
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
24
+ };
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+ export const UPLOAD_TIMEOUT_MS = 20_000;
27
+ /**
28
+ * Resolve the CLI profile's key + base URL from ~/.assethub/config.json.
29
+ * src/index.ts's resolveAuth is module-private, so this mirrors its precedence
30
+ * for the non-interactive case (stored profile, then ASSETHUB_API_KEY).
31
+ */
32
+ export const resolveUploadAuth = async (options) => {
33
+ const env = options.env ?? process.env;
34
+ const configPath = env.ASSETHUB_CLI_CONFIG ?? join(resolveHome(options.home), '.assethub', 'config.json');
35
+ let config = {};
36
+ try {
37
+ config = JSON.parse(await readFile(configPath, 'utf8'));
38
+ }
39
+ catch {
40
+ // no config: fall back to env only
41
+ }
42
+ const name = options.profile ?? config.defaultProfile ?? 'default';
43
+ const stored = config.profiles?.[name];
44
+ const envKey = env.ASSETHUB_API_KEY?.trim();
45
+ // Same precedence as the CLI's own auth: an explicit profile, a selected
46
+ // workspace or a personal profile wins over ASSETHUB_API_KEY.
47
+ const personal = stored?.authentication === 'personal' || Boolean(stored?.apiKey?.startsWith('ah_pat_'));
48
+ const useStored = Boolean(options.profile || stored?.workspaceId || personal);
49
+ const apiKey = useStored ? stored?.apiKey : envKey || stored?.apiKey;
50
+ if (!apiKey)
51
+ return null;
52
+ const baseUrl = (options.profile ? stored?.baseUrl : env.ASSETHUB_API_BASE_URL ?? stored?.baseUrl) ??
53
+ DEFAULT_BASE_URL;
54
+ // The selected workspace belongs to the saved key, not to an ASSETHUB_API_KEY override.
55
+ const workspaceId = apiKey === stored?.apiKey ? stored?.workspaceId : undefined;
56
+ return { apiKey, baseUrl, ...(workspaceId ? { workspaceId } : {}) };
57
+ };
58
+ /** Automatic retries after an ordinary failure wait 1h, 2h, 4h … up to a day. */
59
+ export const retryDelayMs = (failedAttempts) => Math.min(DAY_MS, 60 * 60 * 1000 * 2 ** Math.max(0, failedAttempts - 1));
60
+ const causeName = (error) => {
61
+ const cause = error?.cause;
62
+ return typeof cause?.name === 'string' ? cause.name : undefined;
63
+ };
64
+ /** The upload was too big, or too slow to finish: a smaller graph may get through. */
65
+ const isSizeOrTimeFailure = (error) => {
66
+ if (!(error instanceof RunUploadError)) {
67
+ const name = error?.name;
68
+ return name === 'TimeoutError' || name === 'AbortError';
69
+ }
70
+ if (['push_too_large', 'PAYLOAD_TOO_LARGE'].includes(error.code))
71
+ return true;
72
+ if (error.status === 413 || error.status === 504)
73
+ return true;
74
+ const cause = causeName(error);
75
+ return error.code === 'network_unreachable' && (cause === 'TimeoutError' || cause === 'AbortError');
76
+ };
77
+ /**
78
+ * Whether this login may upload coding-agent sessions (internal accounts for
79
+ * now). The register route answers 404 to anyone it does not let in before it
80
+ * reads the body; for everyone else this empty body fails validation, so
81
+ * nothing is written. Any other answer, or no answer, counts as "no".
82
+ */
83
+ export const canUploadSessions = async (auth, fetchImpl = globalThis.fetch.bind(globalThis)) => {
84
+ try {
85
+ const response = await fetchImpl(controlRunUploadUrls(auth.baseUrl, CODING_AGENT_SESSION_API_PREFIX).graphs, {
86
+ method: 'POST',
87
+ headers: {
88
+ authorization: `Bearer ${auth.apiKey}`,
89
+ 'content-type': 'application/json',
90
+ ...(auth.workspaceId ? { 'x-assethub-workspace': auth.workspaceId } : {}),
91
+ },
92
+ body: '{}',
93
+ signal: AbortSignal.timeout(10_000),
94
+ });
95
+ return response.status === 400 || response.status === 422;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ };
101
+ /**
102
+ * One upload per session at a time: the SessionEnd upload and a background
103
+ * retry would otherwise rebuild the same graph folder under each other.
104
+ */
105
+ export const uploadSession = async (sessionDir, options = {}) => {
106
+ if (!(await readMeta(sessionDir)))
107
+ return { ok: false, reason: 'no-session' };
108
+ const release = await acquireLock(join(sessionDir, '.upload.lock'), { waitMs: 0, staleMs: 15 * 60_000 });
109
+ if (!release)
110
+ return { ok: false, reason: 'upload-in-progress', throttled: true };
111
+ try {
112
+ return await uploadSessionUnlocked(sessionDir, options);
113
+ }
114
+ finally {
115
+ await release();
116
+ }
117
+ };
118
+ const uploadSessionUnlocked = async (sessionDir, options) => {
119
+ const now = options.now ?? (() => new Date());
120
+ let meta = await readMeta(sessionDir);
121
+ if (!meta)
122
+ return { ok: false, reason: 'no-session' };
123
+ if (!options.force && meta.lastUploadAttemptAt) {
124
+ const since = now().getTime() - new Date(meta.lastUploadAttemptAt).getTime();
125
+ if (meta.reason === 'no-upload-access' && since < DAY_MS) {
126
+ return { ok: false, reason: 'no-upload-access', throttled: true };
127
+ }
128
+ if ((meta.failedAttempts ?? 0) > 0 && since < retryDelayMs(meta.failedAttempts ?? 0)) {
129
+ return { ok: false, reason: 'retry-later', throttled: true };
130
+ }
131
+ }
132
+ // Re-read on every write: a hook may have saved the session meanwhile.
133
+ const record = async (patch) => {
134
+ await updateMeta(sessionDir, meta, latest => ({
135
+ ...latest,
136
+ ...patch,
137
+ lastUploadAttemptAt: now().toISOString(),
138
+ }));
139
+ };
140
+ const fail = async (reason, patch = {}) => {
141
+ try {
142
+ const latest = (await readMeta(sessionDir)) ?? meta;
143
+ await record({ status: 'pending-upload', reason, failedAttempts: (latest?.failedAttempts ?? 0) + 1, ...patch });
144
+ }
145
+ catch {
146
+ // keep the original failure as the answer
147
+ }
148
+ return { ok: false, reason };
149
+ };
150
+ const auth = options.auth ?? (await resolveUploadAuth(options));
151
+ if (!auth) {
152
+ await record({ status: 'pending-upload', reason: 'no-credentials' });
153
+ return { ok: false, reason: 'no-credentials' };
154
+ }
155
+ // Copy and redact now if the live transcript moved on since the last copy.
156
+ // Also re-copy a session an older CLI saved whole, so it is trimmed before it leaves the machine.
157
+ if (meta.dirty || meta.transcriptFormat !== TRANSCRIPT_FORMAT)
158
+ meta = (await materializeSession(sessionDir, { env: options.env })) ?? meta;
159
+ let level = meta.compactLevel ?? 0;
160
+ try {
161
+ const baseFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
162
+ const timeoutMs = options.timeoutMs ?? UPLOAD_TIMEOUT_MS;
163
+ // Per request, not one deadline for the whole upload: many blobs of up to
164
+ // 4 MiB each cannot share 20 seconds.
165
+ const fetchImpl = ((input, init) => baseFetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
166
+ for (;;) {
167
+ // Past every rev ever sent: a snapshot whose response was lost may have
168
+ // been published, and re-sending its rev with new content is a permanent
169
+ // conflict. The server accepts gaps, so skipping an unsent rev is harmless.
170
+ const latest = (await readMeta(sessionDir)) ?? meta;
171
+ const rev = Math.max(latest.uploadedRev ?? -1, latest.attemptedRev ?? -1) + 1;
172
+ let plan;
173
+ try {
174
+ const folder = await readGraphFolder(await buildSessionGraphFolder(sessionDir, { compactLevel: level }));
175
+ plan = await buildRunUploadPlan({
176
+ folder,
177
+ graphId: sessionGraphId(basename(sessionDir)),
178
+ rev,
179
+ description: `Agent session ${meta.sessionId}`,
180
+ tags: ['session', `agent:${meta.client ?? 'claude'}`],
181
+ now: now(),
182
+ });
183
+ }
184
+ catch (error) {
185
+ // Too big to send at all: compact and try again right away.
186
+ if (isSizeOrTimeFailure(error) && level < MAX_COMPACT_LEVEL) {
187
+ level += 1;
188
+ continue;
189
+ }
190
+ throw error;
191
+ }
192
+ await record({ attemptedRev: rev, compactLevel: level });
193
+ const result = await uploadRun({
194
+ plan,
195
+ baseUrl: auth.baseUrl,
196
+ apiKey: auth.apiKey,
197
+ workspaceId: auth.workspaceId,
198
+ fetchImpl,
199
+ urls: controlRunUploadUrls(auth.baseUrl, CODING_AGENT_SESSION_API_PREFIX),
200
+ registrationExtra: {
201
+ session: { client: meta.client, sessionId: meta.sessionId, canvasId: canvasIdOf(meta.canvasId) },
202
+ },
203
+ });
204
+ await record({
205
+ status: 'uploaded',
206
+ graphId: result.graphId,
207
+ uploadedRev: rev,
208
+ reason: undefined,
209
+ failedAttempts: 0,
210
+ });
211
+ return { ok: true, graphId: result.graphId };
212
+ }
213
+ }
214
+ catch (error) {
215
+ if (error instanceof RunUploadError && error.status === 404) {
216
+ await record({ status: 'pending-upload', reason: 'no-upload-access' });
217
+ return { ok: false, reason: 'no-upload-access' };
218
+ }
219
+ const reason = redactText(error instanceof Error ? error.message : String(error)).slice(0, 500);
220
+ // Too big or too slow on the wire: the next try sends a smaller graph.
221
+ const compacted = isSizeOrTimeFailure(error) ? { compactLevel: Math.min(level + 1, MAX_COMPACT_LEVEL) } : {};
222
+ return fail(reason, compacted);
223
+ }
224
+ };
225
+ /**
226
+ * Upload pending sessions, newest first. `limit` caps how many are attempted
227
+ * (sessions waiting out a retry delay do not count); `exclude` skips one that
228
+ * the caller already handled.
229
+ */
230
+ export const uploadPending = async (options = {}) => {
231
+ const pending = [];
232
+ for (const dir of await listSessionDirs(options.home)) {
233
+ if (dir === options.exclude)
234
+ continue;
235
+ const meta = await readMeta(dir);
236
+ if (meta?.status !== 'pending-upload')
237
+ continue;
238
+ pending.push({ dir, savedAt: new Date(meta.savedAt).getTime() || 0 });
239
+ }
240
+ pending.sort((a, b) => b.savedAt - a.savedAt);
241
+ const results = [];
242
+ let attempted = 0;
243
+ for (const { dir } of pending) {
244
+ if (options.limit !== undefined && attempted >= options.limit)
245
+ break;
246
+ const outcome = await uploadSession(dir, options);
247
+ results.push({ session: dir, outcome });
248
+ if (!outcome.throttled)
249
+ attempted += 1;
250
+ }
251
+ return results;
252
+ };