@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
package/dist/execution.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { stat } from 'node:fs/promises';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { join } from 'node:path';
@@ -35,8 +35,8 @@ export class CliExecutionError extends Error {
35
35
  operationId;
36
36
  execution;
37
37
  runId;
38
- constructor(message, exitCode = 1, operationId, execution, runId = execution?.runId) {
39
- super(message);
38
+ constructor(message, exitCode = 1, operationId, execution, runId = execution?.runId, options) {
39
+ super(message, options);
40
40
  this.exitCode = exitCode;
41
41
  this.operationId = operationId;
42
42
  this.execution = execution;
@@ -48,12 +48,6 @@ const MIN_POLL_INTERVAL_MS = 1_000;
48
48
  const POLL_JITTER_MS = 250;
49
49
  /** Kept only for SIGINT reporting; interrupting the client never cancels server work. */
50
50
  export const activeExecution = {};
51
- export const childOperationId = (parent, step) => {
52
- const hash = createHash('sha256')
53
- .update(JSON.stringify([parent, step]))
54
- .digest('hex');
55
- return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
56
- };
57
51
  const operationPath = (stateDir, id) => {
58
52
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id))
59
53
  throw new Error('Operation ID must be a UUID');
@@ -130,8 +124,11 @@ const submitSaved = async (session, saved, retryDelayMs = 250) => {
130
124
  return { ...queued, execution: queued.execution };
131
125
  }
132
126
  catch (error) {
127
+ // The agent-run limit does not clear within a retry delay, and the
128
+ // server stores that rejection for this key: a retry only replays it.
133
129
  const retryable = error instanceof TypeError ||
134
130
  (error instanceof AssetHubApiError &&
131
+ error.code !== 'AGENT_OWNER_LIMIT_EXCEEDED' &&
135
132
  ([429, 502, 503, 504].includes(error.status) ||
136
133
  error.code === 'IDEMPOTENCY_IN_PROGRESS'));
137
134
  if (!retryable || attempt >= 2)
@@ -156,7 +153,7 @@ const submitSaved = async (session, saved, retryDelayMs = 250) => {
156
153
  (error.status < 500 ||
157
154
  ['CANVAS_EXECUTION_UNAVAILABLE', 'EVALUATOR_UNAVAILABLE'].includes(error.code))
158
155
  ? 2
159
- : 1, saved.operationId, activeExecution.execution, activeExecution.runId);
156
+ : 1, saved.operationId, activeExecution.execution, activeExecution.runId, { cause: error });
160
157
  }
161
158
  };
162
159
  export const executeRecorded = async (session, operation, body, options = {}) => {
@@ -164,12 +161,14 @@ export const executeRecorded = async (session, operation, body, options = {}) =>
164
161
  throw new CliExecutionError(`Canvas execution history unavailable: ${operation}`, 2);
165
162
  const operationId = options.operationId ?? randomUUID();
166
163
  const path = operationPath(session.stateDir, operationId);
167
- const batchReserved = await stat(join(session.stateDir, 'node-batches', `${operationId}.json`)).then(() => true, error => {
164
+ const reservedBy = async (dir) => stat(join(session.stateDir, dir, `${operationId}.json`)).then(() => true, error => {
168
165
  if (error.code === 'ENOENT')
169
166
  return false;
170
167
  throw error;
171
168
  });
172
- if (batchReserved)
169
+ if (await reservedBy('production-batches'))
170
+ throw new CliExecutionError('Operation ID belongs to a production batch; rerun production batch with it or use a new operation ID', 2, operationId);
171
+ if (await reservedBy('node-batches'))
173
172
  throw new CliExecutionError('Operation ID belongs to a different node batch; use runs resume or a new operation ID', 2, operationId);
174
173
  const saved = JSON.parse(JSON.stringify({
175
174
  operation,
@@ -0,0 +1,14 @@
1
+ export type HooksCommandDeps = {
2
+ home?: string;
3
+ env?: NodeJS.ProcessEnv;
4
+ cwd?: string;
5
+ cliPath?: string;
6
+ readStdin?: () => Promise<string>;
7
+ write?: (text: string) => void;
8
+ fetchImpl?: typeof fetch;
9
+ now?: () => Date;
10
+ startUpload?: (sessionDir: string, profile?: string) => void;
11
+ startSweep?: (profile?: string) => void;
12
+ };
13
+ export declare const runHooksCommand: (argv: string[], deps?: HooksCommandDeps) => Promise<number>;
14
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/hooks/command.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACjC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5D,UAAU,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACxC,CAAA;AA+CD,eAAO,MAAM,eAAe,GAC1B,MAAM,MAAM,EAAE,EACd,OAAM,gBAAqB,KAC1B,OAAO,CAAC,MAAM,CAiIhB,CAAA"}
@@ -0,0 +1,181 @@
1
+ // `assethub hooks <subcommand>` — the registration in src/index.ts just calls
2
+ // runHooksCommand(argv).
3
+ import { installHooks, sessionSaveDisclosure, uninstallHooks } from './install.js';
4
+ import { listSessionDirs, readMeta } from './paths.js';
5
+ import { saveSession } from './save.js';
6
+ import { canUploadSessions, resolveUploadAuth, uploadPending, uploadSession } from './upload.js';
7
+ const USAGE = 'Usage: assethub hooks <install|uninstall|save|upload|list|status> [--client claude|codex] [--json]\n' +
8
+ ' install|uninstall [--global] (the current folder by default; --global for every project)\n' +
9
+ ' save [--client codex] [--transcript <path> --session-id <id>] (reads hook JSON from stdin without flags)\n' +
10
+ ' upload [--session <dir> [--auto] [--sweep <n>]|--pending [--force] [--limit <n>]]\n' +
11
+ ' --auto and --pending wait out earlier failures (1h, 2h, 4h … up to a day); --force retries now';
12
+ const parseFlags = (args) => {
13
+ const flags = {};
14
+ const rest = [];
15
+ for (let i = 0; i < args.length; i += 1) {
16
+ const arg = args[i];
17
+ if (!arg.startsWith('--')) {
18
+ rest.push(arg);
19
+ continue;
20
+ }
21
+ const [name, inline] = arg.slice(2).split('=', 2);
22
+ if (inline !== undefined)
23
+ flags[name] = inline;
24
+ else if (args[i + 1] !== undefined && !args[i + 1].startsWith('--'))
25
+ flags[name] = args[++i];
26
+ else
27
+ flags[name] = true;
28
+ }
29
+ return { flags, rest };
30
+ };
31
+ const limitFlag = (value) => {
32
+ const n = typeof value === 'string' ? Number.parseInt(value, 10) : Number.NaN;
33
+ return Number.isSafeInteger(n) && n >= 0 ? n : undefined;
34
+ };
35
+ const defaultReadStdin = () => new Promise(resolve => {
36
+ if (process.stdin.isTTY)
37
+ return resolve('');
38
+ const chunks = [];
39
+ const timer = setTimeout(() => resolve(Buffer.concat(chunks).toString('utf8')), 2000);
40
+ process.stdin.on('data', chunk => chunks.push(Buffer.from(chunk)));
41
+ process.stdin.on('end', () => {
42
+ clearTimeout(timer);
43
+ resolve(Buffer.concat(chunks).toString('utf8'));
44
+ });
45
+ process.stdin.on('error', () => {
46
+ clearTimeout(timer);
47
+ resolve('');
48
+ });
49
+ });
50
+ export const runHooksCommand = async (argv, deps = {}) => {
51
+ const write = deps.write ?? ((text) => process.stdout.write(text));
52
+ const { flags, rest } = parseFlags(argv);
53
+ const json = flags.json === true;
54
+ const out = (human, data) => write(json ? `${JSON.stringify(data)}\n` : `${human}\n`);
55
+ const [sub] = rest;
56
+ const uploadOptions = {
57
+ home: deps.home,
58
+ env: deps.env,
59
+ fetchImpl: deps.fetchImpl,
60
+ now: deps.now,
61
+ profile: typeof flags.profile === 'string' ? flags.profile : undefined,
62
+ };
63
+ try {
64
+ switch (sub) {
65
+ case 'install':
66
+ case 'uninstall': {
67
+ const client = flags.client;
68
+ if (client !== 'claude' && client !== 'codex') {
69
+ write('--client claude|codex is required\n');
70
+ return 2;
71
+ }
72
+ // Sessions are saved only where the hooks are: this folder, unless --global.
73
+ const scope = flags.global === true ? {} : { projectDir: deps.cwd ?? process.cwd() };
74
+ const paths = { home: deps.home, codexHome: (deps.env ?? process.env).CODEX_HOME?.trim() || undefined };
75
+ if (sub === 'install') {
76
+ // Only accounts that may upload sessions (internal for now) can turn
77
+ // session saving on; for anyone else the hooks would only pile up.
78
+ const auth = await resolveUploadAuth(uploadOptions);
79
+ if (!auth || !(await canUploadSessions(auth, deps.fetchImpl))) {
80
+ write('Session saving is not available for this account.\n');
81
+ return 1;
82
+ }
83
+ const result = await installHooks({ ...scope, ...paths, client, cliPath: deps.cliPath });
84
+ out(result.installed ? `${result.detail}\n${sessionSaveDisclosure(scope, client)}` : result.detail, result);
85
+ return result.installed ? 0 : 1;
86
+ }
87
+ const result = await uninstallHooks({ ...scope, ...paths, client });
88
+ out(result.detail, result);
89
+ return 0;
90
+ }
91
+ case 'save': {
92
+ // A misspelt client must not be saved as Claude. Exit 1, not 2: a
93
+ // hook's exit 2 can block the agent.
94
+ if (flags.client !== undefined && flags.client !== 'claude' && flags.client !== 'codex') {
95
+ write('Nothing saved: use --client claude or --client codex.\n');
96
+ return 1;
97
+ }
98
+ let stdin;
99
+ let input;
100
+ if (typeof flags.transcript === 'string') {
101
+ input = {
102
+ session_id: typeof flags['session-id'] === 'string' ? flags['session-id'] : `manual-${Date.now()}`,
103
+ transcript_path: flags.transcript,
104
+ cwd: deps.cwd ?? process.cwd(),
105
+ hook_event_name: 'Manual',
106
+ };
107
+ }
108
+ else {
109
+ stdin = await (deps.readStdin ?? defaultReadStdin)();
110
+ }
111
+ const result = await saveSession({
112
+ stdin,
113
+ input,
114
+ client: flags.client === 'codex' ? 'codex' : 'claude',
115
+ home: deps.home,
116
+ env: deps.env,
117
+ now: deps.now,
118
+ upload: { profile: uploadOptions.profile },
119
+ startUpload: deps.startUpload,
120
+ startSweep: deps.startSweep,
121
+ });
122
+ // A hook must never fail the agent, so save always exits 0.
123
+ // Claude Code adds SessionStart hook output to the model's context, so
124
+ // that hook stays silent.
125
+ if (result.event !== 'SessionStart') {
126
+ out(result.sessionDir ? `Saved ${result.sessionDir}` : `Nothing saved${result.skipped ? ` (${result.skipped})` : ''}`, result);
127
+ }
128
+ return 0;
129
+ }
130
+ case 'upload': {
131
+ if (typeof flags.session === 'string') {
132
+ // A manual upload retries now; the detached SessionEnd upload (--auto)
133
+ // honours the once-a-day limit after a no-upload-access answer.
134
+ const outcome = await uploadSession(flags.session, { ...uploadOptions, force: flags.auto !== true });
135
+ out(outcome.ok ? `Uploaded ${outcome.graphId}` : `Not uploaded: ${outcome.reason}`, outcome);
136
+ // --sweep N: then retry up to N older pending sessions (SessionEnd does this).
137
+ const sweep = limitFlag(flags.sweep);
138
+ if (sweep !== undefined && sweep > 0) {
139
+ await uploadPending({ ...uploadOptions, force: false, limit: sweep, exclude: flags.session });
140
+ }
141
+ return outcome.ok ? 0 : 1;
142
+ }
143
+ const results = await uploadPending({
144
+ ...uploadOptions,
145
+ force: flags.force === true,
146
+ limit: limitFlag(flags.limit),
147
+ });
148
+ const failed = results.filter(r => !r.outcome.ok).length;
149
+ out(`${results.length - failed}/${results.length} pending sessions uploaded`, results);
150
+ return failed ? 1 : 0;
151
+ }
152
+ case 'list':
153
+ case 'status': {
154
+ const rows = [];
155
+ for (const dir of await listSessionDirs(deps.home)) {
156
+ const meta = await readMeta(dir);
157
+ if (meta)
158
+ rows.push({ dir, sessionId: meta.sessionId, status: meta.status, lastEvent: meta.lastEvent, reason: meta.reason });
159
+ }
160
+ if (sub === 'status') {
161
+ const counts = rows.reduce((acc, r) => {
162
+ acc[r.status] = (acc[r.status] ?? 0) + 1;
163
+ return acc;
164
+ }, {});
165
+ out(`${rows.length} sessions: ${JSON.stringify(counts)}`, { total: rows.length, counts });
166
+ }
167
+ else {
168
+ out(rows.map(r => `${r.status.padEnd(15)} ${r.dir}${r.reason ? ` (${r.reason})` : ''}`).join('\n') || 'No saved sessions', rows);
169
+ }
170
+ return 0;
171
+ }
172
+ default:
173
+ write(`${USAGE}\n`);
174
+ return sub ? 2 : 0;
175
+ }
176
+ }
177
+ catch (error) {
178
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
179
+ return 1;
180
+ }
181
+ };
@@ -0,0 +1,40 @@
1
+ export declare const SESSION_GRAPH_FOLDER = "graph-folder";
2
+ export declare const SESSION_GENERATOR = "assethub-cli:hooks.save";
3
+ export declare const TRANSCRIPT_BLOB_MIME = "application/x-ndjson";
4
+ /** Under the 4 MiB blob limit, with room to spare. */
5
+ export declare const TRANSCRIPT_CHUNK_BYTES: number;
6
+ /**
7
+ * Canonical bytes of the message and tool-call nodes kept in one snapshot. The
8
+ * push is refused over 4 MiB (CONTROL_MAX_PUSH_BYTES), and the edges, image
9
+ * and transcript nodes, manifest and envelope need the rest. A longer session
10
+ * keeps its newest messages in the index and records how many were left out;
11
+ * the transcript blobs still hold every one of them.
12
+ */
13
+ export declare const MAX_MESSAGE_NODE_BYTES: number;
14
+ /**
15
+ * Compaction after an upload failed for size or time. Each level halves the
16
+ * index budget and the per-message clips; from level 2 on images stay local.
17
+ * The transcript blobs are never dropped: they are the record.
18
+ */
19
+ export declare const MAX_COMPACT_LEVEL = 4;
20
+ type Limits = {
21
+ indexBytes: number;
22
+ textChars: number;
23
+ toolInputBytes: number;
24
+ images: boolean;
25
+ };
26
+ export declare const compactLimits: (level: number) => Limits;
27
+ export declare const sessionGraphId: (sessionDirName: string) => string;
28
+ /**
29
+ * Cut on line boundaries into pieces of at most `max` bytes. A single line
30
+ * longer than that (a base64 image inside the JSONL) is cut mid-line; the
31
+ * pieces still concatenate back to the original bytes.
32
+ */
33
+ export declare const chunkTranscript: (bytes: Buffer, max?: number) => Buffer[];
34
+ export declare const buildSessionGraphFolder: (sessionDir: string, options?: {
35
+ compactLevel?: number;
36
+ }) => Promise<string>;
37
+ /** Remove a previously built folder so stale blobs never linger. */
38
+ export declare const resetSessionGraphFolder: (sessionDir: string) => Promise<void>;
39
+ export {};
40
+ //# sourceMappingURL=graph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../src/hooks/graph.ts"],"names":[],"mappings":"AAkCA,eAAO,MAAM,oBAAoB,iBAAiB,CAAA;AAClD,eAAO,MAAM,iBAAiB,4BAA4B,CAAA;AAC1D,eAAO,MAAM,oBAAoB,yBAAyB,CAAA;AAC1D,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,QAAkB,CAAA;AAIrD;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAoB,CAAA;AAIvD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAGlC,KAAK,MAAM,GAAG;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAC,CAAA;AAC9F,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAQ7C,CAAA;AA4BD,eAAO,MAAM,cAAc,GAAI,gBAAgB,MAAM,KAAG,MAMvD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,YAA4B,KAAG,MAAM,EAanF,CAAA;AAED,eAAO,MAAM,uBAAuB,GAClC,YAAY,MAAM,EAClB,UAAS;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAM,KACpC,OAAO,CAAC,MAAM,CAmLhB,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,uBAAuB,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAE9E,CAAA"}
@@ -0,0 +1,269 @@
1
+ // Saved session -> `ag.graph-folder.v1` folder, using the same canonical
2
+ // normalize/hash code the `runs upload` reader recomputes.
3
+ //
4
+ // The whole redacted transcript travels as content-addressed blobs of at most
5
+ // TRANSCRIPT_CHUNK_BYTES, cut on line boundaries. A transcript only grows, so
6
+ // every chunk but the last keeps its hash from one upload to the next and the
7
+ // server already holds it: re-uploading a grown session sends only the tail.
8
+ // The message, prompt and tool-call nodes are an index for browsing and for
9
+ // building skills; the transcript blobs are the record.
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { canonicalGraphHash, canonicalJsonLine, normalizeArtifactGraph, } from '../runsUpload/artifactGraphModel.js';
14
+ import { CONTROL_MAX_BLOB_BYTES } from '../runsUpload/controlEndpoints.js';
15
+ import { EDGES_FILE_NAME, GRAPH_FOLDER_SCHEMA_VERSION, MANIFEST_FILE_NAME, NODES_FILE_NAME, deriveFlows, deriveStates, } from '../runsUpload/graphFolder.js';
16
+ import { readMeta } from './paths.js';
17
+ import { redactText } from './redact.js';
18
+ import { parseTranscript } from './transcript.js';
19
+ export const SESSION_GRAPH_FOLDER = 'graph-folder';
20
+ export const SESSION_GENERATOR = 'assethub-cli:hooks.save';
21
+ export const TRANSCRIPT_BLOB_MIME = 'application/x-ndjson';
22
+ /** Under the 4 MiB blob limit, with room to spare. */
23
+ export const TRANSCRIPT_CHUNK_BYTES = 3 * 1024 * 1024;
24
+ const MAX_TOOL_INPUT_BYTES = 8 * 1024;
25
+ const MAX_TEXT_CHARS = 16 * 1024;
26
+ /**
27
+ * Canonical bytes of the message and tool-call nodes kept in one snapshot. The
28
+ * push is refused over 4 MiB (CONTROL_MAX_PUSH_BYTES), and the edges, image
29
+ * and transcript nodes, manifest and envelope need the rest. A longer session
30
+ * keeps its newest messages in the index and records how many were left out;
31
+ * the transcript blobs still hold every one of them.
32
+ */
33
+ export const MAX_MESSAGE_NODE_BYTES = 2.5 * 1024 * 1024;
34
+ /** Rough canonical size of the `continues` edge that links each kept message. */
35
+ const EDGE_BYTES_ESTIMATE = 160;
36
+ /**
37
+ * Compaction after an upload failed for size or time. Each level halves the
38
+ * index budget and the per-message clips; from level 2 on images stay local.
39
+ * The transcript blobs are never dropped: they are the record.
40
+ */
41
+ export const MAX_COMPACT_LEVEL = 4;
42
+ const SKIP_IMAGES_FROM_LEVEL = 2;
43
+ export const compactLimits = (level) => {
44
+ const factor = 2 ** Math.max(0, Math.min(level, MAX_COMPACT_LEVEL));
45
+ return {
46
+ indexBytes: Math.floor(MAX_MESSAGE_NODE_BYTES / factor),
47
+ textChars: Math.max(1024, Math.floor(MAX_TEXT_CHARS / factor)),
48
+ toolInputBytes: Math.max(512, Math.floor(MAX_TOOL_INPUT_BYTES / factor)),
49
+ images: level < SKIP_IMAGES_FROM_LEVEL,
50
+ };
51
+ };
52
+ const pad = (n) => String(n).padStart(6, '0');
53
+ const clip = (text, max) => text.length > max ? { text: text.slice(0, max), truncated: true } : { text };
54
+ const redactedInput = (input, maxBytes) => {
55
+ const json = redactText(JSON.stringify(input ?? {}) ?? '{}');
56
+ if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
57
+ try {
58
+ const parsed = JSON.parse(json);
59
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
60
+ return parsed;
61
+ }
62
+ return { value: parsed };
63
+ }
64
+ catch {
65
+ // fall through to the preview form
66
+ }
67
+ }
68
+ return { truncated: true, preview: json.slice(0, maxBytes) };
69
+ };
70
+ const isoZ = (value, fallback) => {
71
+ const date = value ? new Date(value) : fallback;
72
+ return `${(Number.isNaN(date.getTime()) ? fallback : date).toISOString().slice(0, 19)}Z`;
73
+ };
74
+ export const sessionGraphId = (sessionDirName) => {
75
+ const slug = sessionDirName
76
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
77
+ .replace(/^[^A-Za-z0-9]+/, '')
78
+ .slice(0, 100);
79
+ return `import_session_${slug}`;
80
+ };
81
+ /**
82
+ * Cut on line boundaries into pieces of at most `max` bytes. A single line
83
+ * longer than that (a base64 image inside the JSONL) is cut mid-line; the
84
+ * pieces still concatenate back to the original bytes.
85
+ */
86
+ export const chunkTranscript = (bytes, max = TRANSCRIPT_CHUNK_BYTES) => {
87
+ const chunks = [];
88
+ let start = 0;
89
+ while (start < bytes.length) {
90
+ let end = Math.min(start + max, bytes.length);
91
+ if (end < bytes.length) {
92
+ const newline = bytes.lastIndexOf(0x0a, end - 1);
93
+ if (newline >= start)
94
+ end = newline + 1;
95
+ }
96
+ chunks.push(bytes.subarray(start, end));
97
+ start = end;
98
+ }
99
+ return chunks;
100
+ };
101
+ export const buildSessionGraphFolder = async (sessionDir, options = {}) => {
102
+ const meta = await readMeta(sessionDir);
103
+ if (!meta)
104
+ throw new Error(`No meta.json in ${sessionDir}`);
105
+ const limits = compactLimits(options.compactLevel ?? 0);
106
+ // A session an older CLI saved has no client: it came from Claude Code.
107
+ const agentTag = `agent:${meta.client ?? 'claude'}`;
108
+ const transcriptBytes = await readFile(join(sessionDir, 'transcript.jsonl'));
109
+ const transcript = parseTranscript(transcriptBytes.toString('utf8'));
110
+ await resetSessionGraphFolder(sessionDir);
111
+ const folder = join(sessionDir, SESSION_GRAPH_FOLDER);
112
+ // The folder holds a full copy of the masked transcript: private like the rest.
113
+ await mkdir(join(folder, 'blobs'), { recursive: true, mode: 0o700 });
114
+ const savedAt = new Date(meta.savedAt);
115
+ const nodes = [];
116
+ const edges = [];
117
+ const edge = (from, to, kind) => {
118
+ edges.push({ id: `edge_${kind}_${from}_${to}`, from, to, kind, tags: [], metadata: {} });
119
+ };
120
+ const blobs = [];
121
+ const blobKeys = new Set();
122
+ const addBlob = async (bytes, mime, name) => {
123
+ const sha = createHash('sha256').update(bytes).digest('hex');
124
+ const key = `sha256:${sha}`;
125
+ if (!blobKeys.has(key)) {
126
+ blobKeys.add(key);
127
+ blobs.push({ key, mime, size: bytes.length, sha256: sha, name, file: `blobs/${sha}` });
128
+ await writeFile(join(folder, 'blobs', sha), bytes, { mode: 0o600 });
129
+ }
130
+ return { key, mime, size: bytes.length, sha256: sha, name };
131
+ };
132
+ const sessionNodeId = 'session';
133
+ // The record: the whole redacted transcript, chunked.
134
+ const transcriptParts = chunkTranscript(transcriptBytes);
135
+ for (const [index, part] of transcriptParts.entries()) {
136
+ const id = `transcript_${pad(index)}`;
137
+ const blobRef = await addBlob(part, TRANSCRIPT_BLOB_MIME, `transcript-${pad(index)}.jsonl`);
138
+ nodes.push({
139
+ id, artifactKind: 'file', semanticType: 'transcript', tags: ['session', agentTag],
140
+ metadata: { part: index, parts: transcriptParts.length },
141
+ payload: { blobRef },
142
+ });
143
+ edge(sessionNodeId, id, 'contains');
144
+ }
145
+ // The index: one node per prompt, reply and tool call.
146
+ const toolNodeById = [];
147
+ const ordered = [];
148
+ const messageNodes = [];
149
+ transcript.items.forEach((item, index) => {
150
+ const id = `${item.kind === 'tool' ? 'tool' : item.kind === 'prompt' ? 'prompt' : 'text'}_${pad(index)}`;
151
+ const createdAt = isoZ(item.timestamp, savedAt);
152
+ const node = item.kind === 'prompt'
153
+ ? {
154
+ id, artifactKind: 'json', semanticType: 'prompt', tags: ['session'],
155
+ metadata: { role: 'user', timestamp: createdAt },
156
+ payload: clip(redactText(item.text), limits.textChars),
157
+ }
158
+ : item.kind === 'assistant'
159
+ ? {
160
+ id, artifactKind: 'text', tags: ['session', agentTag],
161
+ metadata: { role: 'assistant', timestamp: createdAt },
162
+ payload: clip(redactText(item.text), limits.textChars),
163
+ }
164
+ : {
165
+ id, artifactKind: 'run', tags: ['session', agentTag, `tool:${item.toolName}`],
166
+ metadata: { toolUseId: item.toolUseId, timestamp: createdAt },
167
+ payload: { toolName: item.toolName, input: redactedInput(item.input, limits.toolInputBytes) },
168
+ };
169
+ messageNodes.push({ node, item });
170
+ });
171
+ // Keep the newest messages that fit the budget, in their original order.
172
+ let budget = limits.indexBytes;
173
+ let firstKept = messageNodes.length;
174
+ while (firstKept > 0) {
175
+ const cost = Buffer.byteLength(canonicalJsonLine(messageNodes[firstKept - 1].node), 'utf8') + EDGE_BYTES_ESTIMATE;
176
+ if (cost > budget)
177
+ break;
178
+ budget -= cost;
179
+ firstKept -= 1;
180
+ }
181
+ const droppedMessages = firstKept;
182
+ for (const { node, item } of messageNodes.slice(firstKept)) {
183
+ nodes.push(node);
184
+ ordered.push(node.id);
185
+ if (item.kind === 'tool')
186
+ toolNodeById.push({ id: node.id, item });
187
+ }
188
+ // Images: only those small enough for the blob limit, and none once compacted.
189
+ const imageNodes = [];
190
+ let skippedImages = 0;
191
+ for (const [index, image] of (meta.images ?? []).entries()) {
192
+ if (!limits.images) {
193
+ skippedImages += 1;
194
+ continue;
195
+ }
196
+ const file = join(sessionDir, 'images', image.file);
197
+ let bytes;
198
+ try {
199
+ if ((await stat(file)).size > CONTROL_MAX_BLOB_BYTES) {
200
+ skippedImages += 1;
201
+ continue;
202
+ }
203
+ bytes = await readFile(file);
204
+ }
205
+ catch {
206
+ skippedImages += 1;
207
+ continue;
208
+ }
209
+ const blobRef = await addBlob(bytes, image.mime, image.file.slice(0, 200));
210
+ const id = `image_${pad(index)}`;
211
+ nodes.push({
212
+ id, artifactKind: 'image', tags: ['session', agentTag],
213
+ metadata: { sourcePath: redactText(image.original) },
214
+ payload: { blobRef },
215
+ });
216
+ imageNodes.push({ id, original: image.original });
217
+ }
218
+ nodes.push({
219
+ id: sessionNodeId, artifactKind: 'console', semanticType: 'agent-session',
220
+ tags: ['session', agentTag],
221
+ metadata: {
222
+ sessionId: meta.sessionId, client: meta.client, lastEvent: meta.lastEvent,
223
+ messageCount: messageNodes.length, skippedImages,
224
+ transcriptParts: transcriptParts.length,
225
+ ...(droppedMessages > 0 ? { droppedMessages } : {}),
226
+ ...((options.compactLevel ?? 0) > 0 ? { compactLevel: options.compactLevel } : {}),
227
+ ...(meta.canvasId ? { canvasId: meta.canvasId } : {}),
228
+ },
229
+ });
230
+ // Chain: session -> first message, then each message -> the next.
231
+ let previous = sessionNodeId;
232
+ for (const id of ordered) {
233
+ edge(previous, id, 'continues');
234
+ previous = id;
235
+ }
236
+ // 'uses': the tool call that mentioned or produced an image -> the image.
237
+ for (const image of imageNodes) {
238
+ for (const { id, item } of toolNodeById) {
239
+ if (item.kind !== 'tool')
240
+ continue;
241
+ const inInput = JSON.stringify(item.input ?? {}).includes(image.original);
242
+ const inResult = transcript.resultImages.get(item.toolUseId)?.includes(image.original);
243
+ if (inInput || inResult)
244
+ edge(id, image.id, 'uses');
245
+ }
246
+ }
247
+ const graph = normalizeArtifactGraph({ nodes, edges });
248
+ const graphHash = canonicalGraphHash(graph);
249
+ const manifest = {
250
+ schemaVersion: GRAPH_FOLDER_SCHEMA_VERSION,
251
+ createdAt: `${savedAt.toISOString().slice(0, 19)}Z`,
252
+ generator: SESSION_GENERATOR,
253
+ selection: { kind: 'whole-graph' },
254
+ graph: { nodeCount: graph.nodes.length, edgeCount: graph.edges.length, graphHash },
255
+ flows: deriveFlows(graph),
256
+ states: deriveStates(graph),
257
+ blobs: blobs.sort((a, b) => (String(a.key) < String(b.key) ? -1 : 1)),
258
+ missingBlobs: [],
259
+ run: { sessionId: meta.sessionId, client: meta.client },
260
+ };
261
+ await writeFile(join(folder, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
262
+ await writeFile(join(folder, NODES_FILE_NAME), graph.nodes.map(node => `${canonicalJsonLine(node)}\n`).join(''), { mode: 0o600 });
263
+ await writeFile(join(folder, EDGES_FILE_NAME), graph.edges.map(e => `${canonicalJsonLine(e)}\n`).join(''), { mode: 0o600 });
264
+ return folder;
265
+ };
266
+ /** Remove a previously built folder so stale blobs never linger. */
267
+ export const resetSessionGraphFolder = async (sessionDir) => {
268
+ await rm(join(sessionDir, SESSION_GRAPH_FOLDER), { recursive: true, force: true });
269
+ };
@@ -0,0 +1,7 @@
1
+ export { installHooks, uninstallHooks } from './install.js';
2
+ export { saveSession, type SaveSessionOptions, type SaveSessionResult } from './save.js';
3
+ export { buildSessionGraphFolder } from './graph.js';
4
+ export { canUploadSessions, resolveUploadAuth, uploadSession, uploadPending, type UploadOptions } from './upload.js';
5
+ export { runHooksCommand, type HooksCommandDeps } from './command.js';
6
+ export { redactText, redactJsonl } from './redact.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,cAAc,CAAA;AACzD,OAAO,EAAC,WAAW,EAAE,KAAK,kBAAkB,EAAE,KAAK,iBAAiB,EAAC,MAAM,WAAW,CAAA;AACtF,OAAO,EAAC,uBAAuB,EAAC,MAAM,YAAY,CAAA;AAClD,OAAO,EAAC,iBAAiB,EAAE,iBAAiB,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,aAAa,EAAC,MAAM,aAAa,CAAA;AAClH,OAAO,EAAC,eAAe,EAAE,KAAK,gBAAgB,EAAC,MAAM,cAAc,CAAA;AACnE,OAAO,EAAC,UAAU,EAAE,WAAW,EAAC,MAAM,aAAa,CAAA"}
@@ -0,0 +1,6 @@
1
+ export { installHooks, uninstallHooks } from './install.js';
2
+ export { saveSession } from './save.js';
3
+ export { buildSessionGraphFolder } from './graph.js';
4
+ export { canUploadSessions, resolveUploadAuth, uploadSession, uploadPending } from './upload.js';
5
+ export { runHooksCommand } from './command.js';
6
+ export { redactText, redactJsonl } from './redact.js';
@@ -0,0 +1,58 @@
1
+ import type { HookClient } from './types.js';
2
+ export declare const HOOK_EVENTS: readonly ["SessionStart", "Stop", "SessionEnd", "PreCompact"];
3
+ export declare const HOOK_TIMEOUT_SECONDS = 30;
4
+ /**
5
+ * SessionEnd hooks share a 1.5 s budget that a longer per-hook `timeout` raises
6
+ * (up to 60 s). The save itself is local; the upload runs in a detached process.
7
+ */
8
+ export declare const SESSION_END_TIMEOUT_SECONDS = 10;
9
+ export declare const MAX_SETTINGS_BACKUPS = 3;
10
+ /** Where the hooks go: one project folder, or every project when omitted (--global). */
11
+ export type HookScope = {
12
+ projectDir?: string;
13
+ };
14
+ /** Where each client keeps its config, per project and for the user. */
15
+ type ClientPaths = {
16
+ home?: string;
17
+ codexHome?: string;
18
+ };
19
+ /** What `hooks install` and `setup` must tell the user before recording starts. */
20
+ export declare const sessionSaveDisclosure: (scope: HookScope, client?: HookClient) => string;
21
+ export declare const isOurCommand: (command: unknown) => boolean;
22
+ /** Codex hooks name their client, so the saved session is recorded as Codex. */
23
+ export declare const hookCommand: (cliPath?: string, client?: HookClient) => string;
24
+ /**
25
+ * A project install in the home folder would land in ~/.claude (or ~/.codex),
26
+ * which the client also reads for every project, so it is refused rather than
27
+ * silently global.
28
+ */
29
+ export declare const hookScopeError: (home: string | undefined, scope: HookScope, client?: HookClient, codexHome?: string) => Promise<string | undefined>;
30
+ type HookEntry = {
31
+ type: 'command';
32
+ command: string;
33
+ timeout: number;
34
+ async?: true;
35
+ };
36
+ /**
37
+ * SessionStart (sweep and retry), Stop (every turn) and PreCompact only mark or
38
+ * spawn work, and run in the background so they never delay Claude Code.
39
+ * SessionEnd stays synchronous, because Claude Code is exiting and a
40
+ * background hook could be cut off. Codex hooks all run synchronously; the
41
+ * work is the same few local file writes.
42
+ */
43
+ export declare const hookEntry: (event: (typeof HOOK_EVENTS)[number], command: string, client?: HookClient) => HookEntry;
44
+ export declare const installHooks: (options: HookScope & ClientPaths & {
45
+ client: HookClient;
46
+ cliPath?: string;
47
+ }) => Promise<{
48
+ installed: boolean;
49
+ detail: string;
50
+ }>;
51
+ export declare const uninstallHooks: (options: HookScope & ClientPaths & {
52
+ client: HookClient;
53
+ }) => Promise<{
54
+ removed: boolean;
55
+ detail: string;
56
+ }>;
57
+ export {};
58
+ //# sourceMappingURL=install.d.ts.map