hop2 0.11.39 → 0.11.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/web/index.html +2 -1
- package/hay/apps/web/src/App.tsx +24 -6
- package/hay/apps/web/src/components/CwdField.tsx +128 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +227 -35
- package/hay/apps/web/src/styles.css +78 -1
- package/hay/apps/web/src/utils/tabTitle.ts +15 -0
- package/hay/apps/web/src/utils/voiceHold.ts +53 -12
- package/hay/apps/web/test/SessionSwitcher.test.tsx +138 -0
- package/hay/apps/web/test/tabTitle.test.ts +16 -0
- package/hay/apps/web/test/voiceHold.test.ts +46 -0
- package/hop +182 -16
- package/package.json +1 -1
- package/scripts/trajectory-extract.js +234 -0
- package/tests/trajectory-extract.unit.test.cjs +72 -0
package/hop
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { spawn, execSync, execFile, execFileSync } = require('child_process');
|
|
4
|
+
const trajectoryExtract = require('./scripts/trajectory-extract.js');
|
|
4
5
|
const fs = require('fs');
|
|
5
6
|
const path = require('path');
|
|
6
7
|
const crypto = require('crypto');
|
|
@@ -539,6 +540,45 @@ function enforceViewBudget() {
|
|
|
539
540
|
} catch (e) { /* a failed sweep never blocks a publish */ }
|
|
540
541
|
}
|
|
541
542
|
|
|
543
|
+
// The uploads cache's housekeeping — same shape as enforceViewBudget. Runs
|
|
544
|
+
// after every upload AND hourly on a timer, so the cache shrinks even on a
|
|
545
|
+
// quiet day. Copies only: the phone/browser still has the original.
|
|
546
|
+
function enforceUploadBudget() {
|
|
547
|
+
const root = path.join(HOME_DIR, 'uploads');
|
|
548
|
+
try {
|
|
549
|
+
const files = [];
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
for (const sess of fs.readdirSync(root)) {
|
|
552
|
+
const dir = path.join(root, sess);
|
|
553
|
+
let st; try { st = fs.statSync(dir); } catch (e) { continue; }
|
|
554
|
+
if (!st.isDirectory()) continue;
|
|
555
|
+
for (const f of fs.readdirSync(dir)) {
|
|
556
|
+
const p = path.join(dir, f);
|
|
557
|
+
let fst; try { fst = fs.statSync(p); } catch (e) { continue; }
|
|
558
|
+
if (!fst.isFile()) continue;
|
|
559
|
+
if (now - fst.mtimeMs > UPLOADS_MAX_AGE_MS) {
|
|
560
|
+
try { fs.rmSync(p); log(`[uploads] aged out ${sess}/${f}`, DIM); } catch (e) { /* next */ }
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
files.push({ p, sess, f, bytes: fst.size, mtime: fst.mtimeMs });
|
|
564
|
+
}
|
|
565
|
+
// A session folder emptied by the sweep is just clutter.
|
|
566
|
+
try { if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch (e) { /* ignore */ }
|
|
567
|
+
}
|
|
568
|
+
let total = files.reduce((a, b) => a + b.bytes, 0);
|
|
569
|
+
if (total <= UPLOADS_BUDGET_BYTES) return;
|
|
570
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
571
|
+
for (const it of files) {
|
|
572
|
+
if (total <= UPLOADS_BUDGET_BYTES) break;
|
|
573
|
+
try {
|
|
574
|
+
fs.rmSync(it.p);
|
|
575
|
+
total -= it.bytes;
|
|
576
|
+
log(`[uploads] budget: evicted ${it.sess}/${it.f} (${Math.round(it.bytes / 1024)} KB, oldest first)`, DIM);
|
|
577
|
+
} catch (e) { /* the next candidate still evicts */ }
|
|
578
|
+
}
|
|
579
|
+
} catch (e) { /* no uploads dir yet — and a failed sweep never blocks an upload */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
542
582
|
// Does the proxied session behind a Views server row still exist? Answered
|
|
543
583
|
// from the ports ledger on disk (plus the in-memory store, when this is the
|
|
544
584
|
// daemon), so the CLI and the daemon agree.
|
|
@@ -645,6 +685,12 @@ const SESSION_COOKIE_NAME = 'tunnel_session';
|
|
|
645
685
|
const LOG_DIR = path.join(HOME_DIR, 'logs');
|
|
646
686
|
const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
647
687
|
const UPLOAD_MAX_BYTES = parsePositiveIntEnv('HOP_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
|
|
688
|
+
// Uploads (photos from the phone, files dropped on the web) are transient
|
|
689
|
+
// inputs — once the agent has read one it is dead weight, and nothing else
|
|
690
|
+
// ever deleted them (Jian: "we need to clean cache periodically"). Two
|
|
691
|
+
// limits: an AGE sweep and a SIZE budget, oldest evicted first.
|
|
692
|
+
const UPLOADS_BUDGET_BYTES = parsePositiveIntEnv('HOP_UPLOADS_BUDGET_MB', 512) * 1024 * 1024;
|
|
693
|
+
const UPLOADS_MAX_AGE_MS = parsePositiveIntEnv('HOP_UPLOADS_MAX_AGE_DAYS', 14) * 86400000;
|
|
648
694
|
const DEFAULT_WORKSPACE_NAME = 'default';
|
|
649
695
|
const HISTORY_DIR_NAME = 'history';
|
|
650
696
|
const WORKSPACE_LOGS_DIR_NAME = 'logs';
|
|
@@ -914,7 +960,19 @@ function resolveDisplayNameClaim(internalName, claimed, liveIds = null) {
|
|
|
914
960
|
if (claimed === internalName) return claimed;
|
|
915
961
|
const owner = toInternalSessionName(claimed) || claimed;
|
|
916
962
|
const taken = liveIds ? liveIds.has(owner) : hopSessionExists(owner);
|
|
917
|
-
|
|
963
|
+
if (!taken) return claimed;
|
|
964
|
+
// The borrower yields the name, but a minted id ("s_f131c76e88") is not
|
|
965
|
+
// a name a person can use: nine of those appeared in the wall at once
|
|
966
|
+
// when `hop restore` brought back legacy rooms whose ids were the astro
|
|
967
|
+
// names newer sessions had since been given (2026-09-04). Keep the name
|
|
968
|
+
// it was given and mark WHICH one this is — unique, stable, and no
|
|
969
|
+
// lookups, which matters because this runs inside every config read.
|
|
970
|
+
return isMintedSessionId(internalName) ? `${claimed} (${internalName.slice(2, 6)})` : own;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** A machine-minted session id (see mintSessionInternalName), never a name. */
|
|
974
|
+
function isMintedSessionId(internalName) {
|
|
975
|
+
return /^s_[0-9a-f]{10}$/.test(String(internalName || ''));
|
|
918
976
|
}
|
|
919
977
|
|
|
920
978
|
function toDisplaySessionName(internalName) {
|
|
@@ -11073,7 +11131,7 @@ happens until someone clicks it.
|
|
|
11073
11131
|
req.on('data', chunk => body += chunk);
|
|
11074
11132
|
req.on('end', async () => {
|
|
11075
11133
|
try {
|
|
11076
|
-
const { name, internalName } = JSON.parse(body);
|
|
11134
|
+
const { name, internalName, target } = JSON.parse(body);
|
|
11077
11135
|
const source = resolveSessionInternalName(name, internalName);
|
|
11078
11136
|
if (!source || !(hopSessionExists(source) || getEffectiveSessionConfig(source))) {
|
|
11079
11137
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -11094,27 +11152,82 @@ happens until someone clicks it.
|
|
|
11094
11152
|
const plausibleCwd = (c) => c && !/[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(c));
|
|
11095
11153
|
const cwd = [rec?.cwd, meta?.cwd, config.cwd].find(plausibleCwd) || undefined;
|
|
11096
11154
|
|
|
11097
|
-
|
|
11098
|
-
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
11106
|
-
|
|
11155
|
+
// What the source is running: a claude conversation (the
|
|
11156
|
+
// hook's record), codex (by process — codex has no hook),
|
|
11157
|
+
// or a plain shell.
|
|
11158
|
+
const runsCodex = /codex/i.test(String(config.foregroundProcess || ''))
|
|
11159
|
+
|| /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
|
|
11160
|
+
const sourceKind = rec ? 'claude' : runsCodex ? 'codex' : 'shell';
|
|
11161
|
+
const wantTarget = typeof target === 'string' && target.trim() ? target.trim().toLowerCase() : null;
|
|
11162
|
+
if (wantTarget && wantTarget !== 'claude' && wantTarget !== 'codex') {
|
|
11163
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
11164
|
+
res.end(JSON.stringify({ error: `Unknown fork target "${wantTarget}" — claude or codex` }));
|
|
11165
|
+
return;
|
|
11166
|
+
}
|
|
11167
|
+
// Crossing tools is a HANDOFF, not a fork: neither tool can
|
|
11168
|
+
// resume the other's transcript, so the conversation is
|
|
11169
|
+
// extracted to a document the new agent reads before its
|
|
11170
|
+
// first turn (scripts/trajectory-extract.js). Same tool,
|
|
11171
|
+
// or no target, is the conversation fork as before.
|
|
11172
|
+
const handoff = !!wantTarget && wantTarget !== sourceKind;
|
|
11173
|
+
if (handoff && sourceKind === 'shell') {
|
|
11174
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
11175
|
+
res.end(JSON.stringify({ error: 'This session has no claude or codex conversation to hand off' }));
|
|
11176
|
+
return;
|
|
11107
11177
|
}
|
|
11178
|
+
const codexRollout = sourceKind === 'codex' ? resolveCodexTranscriptByCwd(cwd) : null;
|
|
11108
11179
|
|
|
11109
11180
|
const sourceDisplay = getSessionDisplayName(source) || source;
|
|
11110
|
-
|
|
11181
|
+
const suffix = handoff ? '-' + wantTarget : '-fork';
|
|
11182
|
+
let forkName = sourceDisplay + suffix;
|
|
11111
11183
|
let n = 2;
|
|
11112
11184
|
while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
|
|
11113
|
-
forkName = sourceDisplay +
|
|
11185
|
+
forkName = sourceDisplay + suffix + n;
|
|
11114
11186
|
n += 1;
|
|
11115
11187
|
}
|
|
11116
11188
|
const forkInternal = toInternalSessionName(forkName);
|
|
11117
11189
|
|
|
11190
|
+
let command;
|
|
11191
|
+
let kind = 'shell';
|
|
11192
|
+
let extract = null;
|
|
11193
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
11194
|
+
if (handoff) {
|
|
11195
|
+
const transcript = sourceKind === 'claude' ? resolveClaudeTranscriptPath(rec) : codexRollout;
|
|
11196
|
+
if (!transcript) {
|
|
11197
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
11198
|
+
res.end(JSON.stringify({ error: `No ${sourceKind} transcript found for ${sourceDisplay} to hand off` }));
|
|
11199
|
+
return;
|
|
11200
|
+
}
|
|
11201
|
+
// Budget by what the RECEIVER can hold: Codex's context is
|
|
11202
|
+
// a fraction of Claude's, and a document it cannot finish
|
|
11203
|
+
// reading is worse than a shorter one it can.
|
|
11204
|
+
const maxChars = wantTarget === 'codex' ? 400000 : 800000;
|
|
11205
|
+
const result = await trajectoryExtract.extractConversation(transcript, { tool: sourceKind, maxChars });
|
|
11206
|
+
// Staged where uploads for the new session live: the
|
|
11207
|
+
// one per-session directory that already exists for
|
|
11208
|
+
// "a file this session is meant to read".
|
|
11209
|
+
const stage = path.join(HOME_DIR, 'uploads', forkInternal);
|
|
11210
|
+
fs.mkdirSync(stage, { recursive: true, mode: 0o700 });
|
|
11211
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
11212
|
+
const extractPath = path.join(stage, `handoff-from-${sourceDisplay}-${stamp}.md`);
|
|
11213
|
+
writePrivateFile(extractPath, result.text);
|
|
11214
|
+
const bytes = Buffer.byteLength(result.text);
|
|
11215
|
+
const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
|
|
11216
|
+
command = `${wantTarget} ${shq(prompt)}`;
|
|
11217
|
+
kind = 'handoff';
|
|
11218
|
+
extract = { path: extractPath, bytes, turns: result.turns, totalTurns: result.totalTurns };
|
|
11219
|
+
} else if (rec) {
|
|
11220
|
+
const base = rebuildClaudeLaunch(rec.launchCmd) || 'claude';
|
|
11221
|
+
command = base + ' --resume ' + rec.sessionId + ' --fork-session';
|
|
11222
|
+
kind = 'claude';
|
|
11223
|
+
} else if (runsCodex) {
|
|
11224
|
+
// By id when the rollout is identifiable — the picker
|
|
11225
|
+
// otherwise, which asks the user to find it.
|
|
11226
|
+
const rolloutId = codexRollout ? await trajectoryExtract.codexRolloutId(codexRollout) : null;
|
|
11227
|
+
command = rolloutId ? `codex resume ${shq(rolloutId)}` : 'codex resume';
|
|
11228
|
+
kind = 'codex';
|
|
11229
|
+
}
|
|
11230
|
+
|
|
11118
11231
|
// The fork inherits the source's origin: forking an agent
|
|
11119
11232
|
// worker makes another agent worker; forking your own
|
|
11120
11233
|
// session makes yours.
|
|
@@ -11137,10 +11250,11 @@ happens until someone clicks it.
|
|
|
11137
11250
|
displayName: forkName,
|
|
11138
11251
|
cwd,
|
|
11139
11252
|
createdBy,
|
|
11140
|
-
forkedFrom: source
|
|
11253
|
+
forkedFrom: source,
|
|
11254
|
+
...(handoff ? { handoffFrom: sourceKind, handoffTo: wantTarget } : {})
|
|
11141
11255
|
});
|
|
11142
11256
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
11143
|
-
res.end(JSON.stringify({ ok: true, name: forkName, internalName: forkInternal, kind }));
|
|
11257
|
+
res.end(JSON.stringify({ ok: true, name: forkName, internalName: forkInternal, kind, target: handoff ? wantTarget : sourceKind, extract }));
|
|
11144
11258
|
} catch (e) {
|
|
11145
11259
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
11146
11260
|
res.end(JSON.stringify({ error: e?.message || 'Fork failed' }));
|
|
@@ -11386,6 +11500,7 @@ happens until someone clicks it.
|
|
|
11386
11500
|
fs.mkdirSync(dir, { recursive: true });
|
|
11387
11501
|
const filePath = uniqueUploadPath(dir, safeUploadFilename(parsedUrl.query.filename));
|
|
11388
11502
|
fs.writeFileSync(filePath, Buffer.concat(chunks), { mode: 0o600 });
|
|
11503
|
+
enforceUploadBudget(); // every upload pays its way; hourly timer covers idle days
|
|
11389
11504
|
log(`[upload] ${getSessionDisplayName(internalName) || internalName}: ${path.basename(filePath)} (${received} bytes)`, DIM);
|
|
11390
11505
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
11391
11506
|
res.end(JSON.stringify({ ok: true, path: filePath, name: path.basename(filePath), bytes: received }));
|
|
@@ -11573,6 +11688,17 @@ happens until someone clicks it.
|
|
|
11573
11688
|
req.on('end', async () => {
|
|
11574
11689
|
try {
|
|
11575
11690
|
let { name, type, port, cwd, folderId, startRuntime, workspace, workspaceName, attachedTo, startup } = JSON.parse(body);
|
|
11691
|
+
// The web create form takes the directory as the user
|
|
11692
|
+
// typed it, ~ included; the host only checks that the
|
|
11693
|
+
// path exists, so an unexpanded ~ silently fell back to
|
|
11694
|
+
// home. Expand it here, once, for every caller.
|
|
11695
|
+
if (typeof cwd === 'string') {
|
|
11696
|
+
const t = cwd.trim();
|
|
11697
|
+
cwd = t === '' ? undefined
|
|
11698
|
+
: t === '~' ? os.homedir()
|
|
11699
|
+
: t.startsWith('~/') ? path.join(os.homedir(), t.slice(2))
|
|
11700
|
+
: t;
|
|
11701
|
+
}
|
|
11576
11702
|
// A new session can boot straight into an app instead of a
|
|
11577
11703
|
// bare shell: the web New picker sends startup='claude' /
|
|
11578
11704
|
// 'codex' (or empty for a plain terminal). Bounded, and
|
|
@@ -11901,6 +12027,40 @@ happens until someone clicks it.
|
|
|
11901
12027
|
}
|
|
11902
12028
|
|
|
11903
12029
|
// API: Restore saved sessions (recreate rooms + resume claude conversations)
|
|
12030
|
+
if (parsedUrl.pathname === '/api/fs/complete' && req.method === 'GET') {
|
|
12031
|
+
// Directory completion for the create form's working-directory
|
|
12032
|
+
// field. Directories only: the last segment of `q` is a prefix
|
|
12033
|
+
// (case-insensitive), everything before it must already exist. A
|
|
12034
|
+
// leading ~ is the home directory and a relative path resolves
|
|
12035
|
+
// under it, matching what a shell would do with the same text.
|
|
12036
|
+
// Dot-directories appear only once the prefix itself starts with a
|
|
12037
|
+
// dot, the way tab completion behaves.
|
|
12038
|
+
const sp = parsedUrl.searchParams || new URLSearchParams(parsedUrl.search || '');
|
|
12039
|
+
const q = String(sp.get('q') || '').trim();
|
|
12040
|
+
const home = os.homedir();
|
|
12041
|
+
const trailing = q.endsWith('/');
|
|
12042
|
+
const expanded = q === '' || q === '~' ? home + '/'
|
|
12043
|
+
: q.startsWith('~/') ? path.join(home, q.slice(2)) + (trailing ? '/' : '')
|
|
12044
|
+
: q.startsWith('/') ? q
|
|
12045
|
+
: path.join(home, q) + (trailing ? '/' : '');
|
|
12046
|
+
const dir = expanded.endsWith('/') ? expanded.replace(/\/+$/, '') || '/' : path.dirname(expanded);
|
|
12047
|
+
const prefix = expanded.endsWith('/') ? '' : path.basename(expanded).toLowerCase();
|
|
12048
|
+
const entries = [];
|
|
12049
|
+
try {
|
|
12050
|
+
for (const name of fs.readdirSync(dir)) {
|
|
12051
|
+
if (!name.toLowerCase().startsWith(prefix)) continue;
|
|
12052
|
+
if (name.startsWith('.') && !prefix.startsWith('.')) continue;
|
|
12053
|
+
const full = path.join(dir, name);
|
|
12054
|
+
try { if (!fs.statSync(full).isDirectory()) continue; } catch (e) { continue; }
|
|
12055
|
+
entries.push({ name, path: full });
|
|
12056
|
+
if (entries.length >= 40) break;
|
|
12057
|
+
}
|
|
12058
|
+
} catch (e) { /* not a directory, or unreadable: no completions */ }
|
|
12059
|
+
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
12060
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
12061
|
+
res.end(JSON.stringify({ home, dir, entries }));
|
|
12062
|
+
return;
|
|
12063
|
+
}
|
|
11904
12064
|
if (parsedUrl.pathname === '/api/sessions/restore/health' && req.method === 'GET') {
|
|
11905
12065
|
const state = readRestoreReconcile();
|
|
11906
12066
|
const sessions = Object.values(state.sessions || {});
|
|
@@ -18245,6 +18405,12 @@ async function main() {
|
|
|
18245
18405
|
};
|
|
18246
18406
|
setTimeout(runRestoreReconcile, 12000).unref?.();
|
|
18247
18407
|
setInterval(runRestoreReconcile, 30000).unref?.();
|
|
18408
|
+
|
|
18409
|
+
// Uploads cache: age out + size-cap, once at boot (a daemon that sat
|
|
18410
|
+
// idle for a week still cleans up) and then hourly. Cheap: one stat
|
|
18411
|
+
// per file, and a no-op when the folder doesn't exist yet.
|
|
18412
|
+
setTimeout(enforceUploadBudget, 20000).unref?.();
|
|
18413
|
+
setInterval(enforceUploadBudget, 3600000).unref?.();
|
|
18248
18414
|
}
|
|
18249
18415
|
|
|
18250
18416
|
if (DAEMON_FLAG) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Turn a Claude Code or Codex transcript into a readable handoff document.
|
|
3
|
+
//
|
|
4
|
+
// This is how a conversation crosses tools. A Claude transcript is a JSONL
|
|
5
|
+
// of API messages; a Codex rollout is a JSONL of events; neither tool can
|
|
6
|
+
// resume the other's. What CAN cross is the conversation itself — every user
|
|
7
|
+
// turn, the assistant's reply, and a one-line summary of each action taken —
|
|
8
|
+
// written as markdown the receiving agent reads before its first turn. That
|
|
9
|
+
// is context sharing, not replay: tool results are not carried, and the
|
|
10
|
+
// receiver starts by summarising what it read.
|
|
11
|
+
//
|
|
12
|
+
// Same document format as ~/Code/agent_tools/agent_migration.py --extract,
|
|
13
|
+
// ported so hop needs no Python (and no TUI dependencies) to hand a session
|
|
14
|
+
// off — with replies paired to the turn they answer (see readClaudeTurns).
|
|
15
|
+
//
|
|
16
|
+
// Streams line by line: real transcripts run to hundreds of MB.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const readline = require('readline');
|
|
20
|
+
|
|
21
|
+
const NOISE_PREFIXES = [
|
|
22
|
+
'<task-notification>', '<turn_aborted>', '<local-command-caveat>',
|
|
23
|
+
'[Request interrupted', '<command-name>', '<system-reminder>',
|
|
24
|
+
'# AGENTS.md', '<INSTRUCTIONS>', '<local-command-stdout>',
|
|
25
|
+
'Automation:', '<subagent_notification>'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function summarizeToolCall(block) {
|
|
29
|
+
const name = block?.name || '?';
|
|
30
|
+
const inp = (block && typeof block.input === 'object' && block.input) || {};
|
|
31
|
+
switch (name) {
|
|
32
|
+
case 'Bash': return `[Bash] ${String(inp.command || '').slice(0, 120)}`;
|
|
33
|
+
case 'Read': return `[Read] ${inp.file_path || '?'}`;
|
|
34
|
+
case 'Edit': return `[Edit] ${inp.file_path || '?'}`;
|
|
35
|
+
case 'Write': return `[Write] ${inp.file_path || '?'}`;
|
|
36
|
+
case 'Glob': return `[Glob] ${inp.pattern || '?'}`;
|
|
37
|
+
case 'Grep': return `[Grep] ${inp.pattern || '?'}`;
|
|
38
|
+
case 'Agent': return `[Agent] ${String(inp.prompt || '?').slice(0, 80)}`;
|
|
39
|
+
default: {
|
|
40
|
+
const short = name.includes('__') ? name.split('__').pop() : name;
|
|
41
|
+
const args = Object.entries(inp).slice(0, 2).map(([k, v]) => `${k}=${String(v).slice(0, 30)}`).join(' ');
|
|
42
|
+
return `[${short}] ${args}`.slice(0, 120);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function eachJsonLine(file, onEntry) {
|
|
48
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
49
|
+
for await (const line of rl) {
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
let entry;
|
|
52
|
+
try { entry = JSON.parse(line); } catch (e) { continue; }
|
|
53
|
+
if (onEntry(entry) === false) { rl.close(); break; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Claude Code transcript → [{ text, ts, assistant, assistantTs, tools }].
|
|
59
|
+
*
|
|
60
|
+
* A reply belongs to the user turn it answers. agent_migration.py attached
|
|
61
|
+
* each assistant message to the NEXT user turn and overwrote earlier text
|
|
62
|
+
* blocks, so a reply could land on a harness message (a <system-reminder>)
|
|
63
|
+
* and vanish with it when noise was filtered. Here every assistant text and
|
|
64
|
+
* tool call accrues to the most recent real user turn; harness messages and
|
|
65
|
+
* tool results never become turns at all.
|
|
66
|
+
*/
|
|
67
|
+
async function readClaudeTurns(file) {
|
|
68
|
+
const turns = [];
|
|
69
|
+
let current = null;
|
|
70
|
+
await eachJsonLine(file, (entry) => {
|
|
71
|
+
const ts = String(entry.timestamp || '');
|
|
72
|
+
if (entry.type === 'assistant') {
|
|
73
|
+
const msg = entry.message;
|
|
74
|
+
if (!msg || typeof msg !== 'object') return;
|
|
75
|
+
for (const block of Array.isArray(msg.content) ? msg.content : []) {
|
|
76
|
+
if (!block || typeof block !== 'object') continue;
|
|
77
|
+
if (block.type === 'text' && block.text) {
|
|
78
|
+
if (current) {
|
|
79
|
+
current.assistant = current.assistant ? `${current.assistant}\n${block.text}` : String(block.text);
|
|
80
|
+
current.assistantTs = ts.slice(0, 19);
|
|
81
|
+
}
|
|
82
|
+
} else if (block.type === 'tool_use') {
|
|
83
|
+
if (current) current.tools.push(summarizeToolCall(block));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else if (entry.type === 'user') {
|
|
87
|
+
const msg = entry.message;
|
|
88
|
+
const content = msg && typeof msg === 'object' ? msg.content : '';
|
|
89
|
+
let text = '';
|
|
90
|
+
if (typeof content === 'string') text = content.trim();
|
|
91
|
+
else if (Array.isArray(content)) {
|
|
92
|
+
for (const block of content) {
|
|
93
|
+
if (block && typeof block === 'object' && block.type === 'text') { text = String(block.text || '').trim(); break; }
|
|
94
|
+
if (typeof block === 'string') { text = block.trim(); break; }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!text || NOISE_PREFIXES.some((p) => text.startsWith(p))) return;
|
|
98
|
+
current = { text, ts: ts.slice(0, 19), assistant: '', assistantTs: '', tools: [] };
|
|
99
|
+
turns.push(current);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return turns;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Codex rollout → same shape (no tool summaries: rollouts do not carry them). */
|
|
106
|
+
async function readCodexTurns(file) {
|
|
107
|
+
const turns = [];
|
|
108
|
+
let current = null;
|
|
109
|
+
await eachJsonLine(file, (entry) => {
|
|
110
|
+
const ts = String(entry.timestamp || '');
|
|
111
|
+
const payload = (entry.payload && typeof entry.payload === 'object') ? entry.payload : {};
|
|
112
|
+
if (entry.type === 'response_item' && payload.role === 'assistant' && payload.type === 'message') {
|
|
113
|
+
const parts = [];
|
|
114
|
+
for (const block of Array.isArray(payload.content) ? payload.content : []) {
|
|
115
|
+
if (block && typeof block === 'object' && block.type === 'output_text' && block.text) parts.push(block.text);
|
|
116
|
+
}
|
|
117
|
+
if (parts.length && current) {
|
|
118
|
+
const text = parts.join('\n');
|
|
119
|
+
current.assistant = current.assistant ? `${current.assistant}\n${text}` : text;
|
|
120
|
+
current.assistantTs = ts.slice(0, 19);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let text = '';
|
|
125
|
+
if (entry.type === 'event_msg' && payload.type === 'user_message') {
|
|
126
|
+
text = String(payload.message || '').trim();
|
|
127
|
+
} else if (entry.type === 'response_item' && payload.role === 'user' && payload.type === 'message') {
|
|
128
|
+
for (const block of Array.isArray(payload.content) ? payload.content : []) {
|
|
129
|
+
if (block && typeof block === 'object' && block.type === 'input_text') { text = String(block.text || '').trim(); break; }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!text || text.length <= 3) return;
|
|
133
|
+
// Codex logs a user turn twice (event + response item); keep one.
|
|
134
|
+
if (current && current.text.slice(0, 100) === text.slice(0, 100) && current.ts === ts.slice(0, 19)) return;
|
|
135
|
+
current = { text, ts: ts.slice(0, 19), assistant: '', assistantTs: '', tools: [] };
|
|
136
|
+
turns.push(current);
|
|
137
|
+
});
|
|
138
|
+
return turns;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectTool(file) {
|
|
142
|
+
if (/[\\/]\.codex[\\/]/.test(file)) return 'codex';
|
|
143
|
+
if (/[\\/]\.gemini[\\/]/.test(file)) return 'gemini';
|
|
144
|
+
return 'claude';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The working directory the transcript was recorded in, from its first entry that says. */
|
|
148
|
+
async function readTranscriptCwd(file) {
|
|
149
|
+
let cwd = 'unknown';
|
|
150
|
+
await eachJsonLine(file, (entry) => {
|
|
151
|
+
const c = entry.cwd || (entry.payload && entry.payload.cwd);
|
|
152
|
+
if (typeof c === 'string' && c) { cwd = c; return false; }
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
return cwd;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A Codex rollout's own session id, for `codex resume <id>`. */
|
|
159
|
+
async function codexRolloutId(file) {
|
|
160
|
+
let id = null;
|
|
161
|
+
await eachJsonLine(file, (entry) => {
|
|
162
|
+
if (entry.type === 'session_meta' && entry.payload && typeof entry.payload.id === 'string') id = entry.payload.id;
|
|
163
|
+
return false;
|
|
164
|
+
});
|
|
165
|
+
return id;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the handoff document. Turns are taken from the END until the
|
|
170
|
+
* character budget fills — the most recent context is what the receiver
|
|
171
|
+
* needs — then harness noise is dropped.
|
|
172
|
+
*/
|
|
173
|
+
async function extractConversation(file, { maxChars = 800000, tool } = {}) {
|
|
174
|
+
const kind = tool || detectTool(file);
|
|
175
|
+
if (kind === 'gemini') throw new Error('Gemini transcripts are not supported for handoff');
|
|
176
|
+
const turns = kind === 'codex' ? await readCodexTurns(file) : await readClaudeTurns(file);
|
|
177
|
+
const selected = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
180
|
+
const t = turns[i];
|
|
181
|
+
const size = t.text.length + (t.assistant || '').length + (t.tools || []).reduce((n, x) => n + x.length, 0);
|
|
182
|
+
if (total + size > maxChars && selected.length) break;
|
|
183
|
+
selected.push(t);
|
|
184
|
+
total += size;
|
|
185
|
+
}
|
|
186
|
+
selected.reverse();
|
|
187
|
+
const kept = selected.filter((t) => !NOISE_PREFIXES.some((p) => t.text.trim().startsWith(p)));
|
|
188
|
+
const cwd = await readTranscriptCwd(file);
|
|
189
|
+
const lines = [
|
|
190
|
+
'# Conversation Extract',
|
|
191
|
+
`# Source tool: ${kind}`,
|
|
192
|
+
`# Source file: ${file}`,
|
|
193
|
+
`# Working directory: ${cwd}`
|
|
194
|
+
];
|
|
195
|
+
if (turns.length) {
|
|
196
|
+
lines.push(`# First message: ${turns[0].ts || 'unknown'}`);
|
|
197
|
+
lines.push(`# Last message: ${turns[turns.length - 1].ts || 'unknown'}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push(`# Turns included below: ${kept.length} (most recent, within token budget)`);
|
|
200
|
+
lines.push(`# Extracted: ${new Date().toISOString().slice(0, 19)}`);
|
|
201
|
+
lines.push('');
|
|
202
|
+
for (const t of kept) {
|
|
203
|
+
lines.push(`## User [${t.ts || ''}]`, t.text, '');
|
|
204
|
+
if (t.tools && t.tools.length) {
|
|
205
|
+
lines.push('### Actions taken:');
|
|
206
|
+
for (const x of t.tools) lines.push(` ${x}`);
|
|
207
|
+
lines.push('');
|
|
208
|
+
}
|
|
209
|
+
if (t.assistant) lines.push('## Assistant', t.assistant, '');
|
|
210
|
+
}
|
|
211
|
+
return { text: lines.join('\n'), tool: kind, cwd, turns: kept.length, totalTurns: turns.length };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The instruction the receiving agent starts with. */
|
|
215
|
+
function handoffPrompt(extractPath, bytes, sourceTool) {
|
|
216
|
+
const kb = Math.max(1, Math.round(bytes / 1024));
|
|
217
|
+
return `Read the ENTIRE file ${extractPath} (${kb}KB) from start to finish — do not stop at the first chunk. `
|
|
218
|
+
+ `It contains a previous ${sourceTool} conversation. After reading all of it, summarize what we were working on and ask me what to do next.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { extractConversation, readClaudeTurns, readCodexTurns, summarizeToolCall, detectTool, codexRolloutId, handoffPrompt };
|
|
222
|
+
|
|
223
|
+
if (require.main === module) {
|
|
224
|
+
const argv = process.argv.slice(2);
|
|
225
|
+
const file = argv.find((a) => !a.startsWith('-'));
|
|
226
|
+
if (!file) {
|
|
227
|
+
console.error('Usage: node scripts/trajectory-extract.js <transcript.jsonl> [--max-chars N] [--tool claude|codex]');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
|
|
231
|
+
extractConversation(file, { maxChars: Number(flag('--max-chars')) || undefined, tool: flag('--tool') })
|
|
232
|
+
.then((r) => process.stdout.write(r.text))
|
|
233
|
+
.catch((e) => { console.error(e.message); process.exit(1); });
|
|
234
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { extractConversation, codexRolloutId, handoffPrompt } = require('../scripts/trajectory-extract.js');
|
|
8
|
+
|
|
9
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hop-extract-'));
|
|
10
|
+
const jsonl = (file, entries) => fs.writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
11
|
+
|
|
12
|
+
test('claude transcript: turns, tool summaries, noise dropped, most-recent-first budget', async () => {
|
|
13
|
+
const dir = tmp();
|
|
14
|
+
const file = path.join(dir, 'claude.jsonl');
|
|
15
|
+
jsonl(file, [
|
|
16
|
+
{ type: 'user', timestamp: '2026-09-01T10:00:00.000Z', cwd: '/Users/me/proj', message: { role: 'user', content: 'fix the build' } },
|
|
17
|
+
{ type: 'assistant', timestamp: '2026-09-01T10:00:05.000Z', message: { role: 'assistant', content: [
|
|
18
|
+
{ type: 'text', text: 'Looking.' },
|
|
19
|
+
{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
|
|
20
|
+
{ type: 'tool_use', name: 'Edit', input: { file_path: '/Users/me/proj/a.ts' } }
|
|
21
|
+
] } },
|
|
22
|
+
{ type: 'user', timestamp: '2026-09-01T10:00:06.000Z', message: { role: 'user', content: [{ type: 'tool_result', content: 'ok' }] } },
|
|
23
|
+
{ type: 'assistant', timestamp: '2026-09-01T10:00:09.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Fixed a.ts.' }] } },
|
|
24
|
+
{ type: 'user', timestamp: '2026-09-01T10:01:00.000Z', message: { role: 'user', content: '<system-reminder>ignore me</system-reminder>' } },
|
|
25
|
+
{ type: 'user', timestamp: '2026-09-01T10:02:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'now ship it' }] } },
|
|
26
|
+
{ type: 'assistant', timestamp: '2026-09-01T10:02:30.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Shipped.' }] } }
|
|
27
|
+
]);
|
|
28
|
+
const r = await extractConversation(file);
|
|
29
|
+
assert.equal(r.tool, 'claude');
|
|
30
|
+
assert.equal(r.cwd, '/Users/me/proj');
|
|
31
|
+
assert.equal(r.totalTurns, 2); // the tool result and the system-reminder are not turns
|
|
32
|
+
assert.equal(r.turns, 2);
|
|
33
|
+
assert.match(r.text, /^# Conversation Extract\n# Source tool: claude/);
|
|
34
|
+
assert.match(r.text, /## User \[2026-09-01T10:00:00\]\nfix the build\n/);
|
|
35
|
+
assert.match(r.text, /### Actions taken:\n {2}\[Bash\] npm test\n {2}\[Edit\] \/Users\/me\/proj\/a\.ts/);
|
|
36
|
+
// Both text blocks of the reply, in order, on the turn they answer.
|
|
37
|
+
assert.match(r.text, /## Assistant\nLooking\.\nFixed a\.ts\.\n/);
|
|
38
|
+
assert.ok(!r.text.includes('ignore me'));
|
|
39
|
+
assert.ok(r.text.indexOf('fix the build') < r.text.indexOf('now ship it'));
|
|
40
|
+
assert.match(r.text, /## Assistant\nShipped\.\n$/);
|
|
41
|
+
|
|
42
|
+
// A tight budget keeps the NEWEST turn.
|
|
43
|
+
const small = await extractConversation(file, { maxChars: 20 });
|
|
44
|
+
assert.equal(small.turns, 1);
|
|
45
|
+
assert.ok(small.text.includes('now ship it'));
|
|
46
|
+
assert.ok(!small.text.includes('fix the build'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('codex rollout: user turns deduped, assistant paired, id readable', async () => {
|
|
50
|
+
const dir = tmp();
|
|
51
|
+
const file = path.join(dir, '.codex', 'sessions', 'rollout.jsonl');
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
jsonl(file, [
|
|
54
|
+
{ type: 'session_meta', timestamp: '2026-09-01T11:00:00.000Z', payload: { id: 'abc-123', cwd: '/Users/me/other' } },
|
|
55
|
+
{ type: 'event_msg', timestamp: '2026-09-01T11:00:01.000Z', payload: { type: 'user_message', message: 'summarize the repo' } },
|
|
56
|
+
{ type: 'response_item', timestamp: '2026-09-01T11:00:01.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'summarize the repo' }] } },
|
|
57
|
+
{ type: 'response_item', timestamp: '2026-09-01T11:00:20.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'It is a CLI.' }] } },
|
|
58
|
+
{ type: 'event_msg', timestamp: '2026-09-01T11:05:00.000Z', payload: { type: 'user_message', message: 'ok' } }
|
|
59
|
+
]);
|
|
60
|
+
const r = await extractConversation(file);
|
|
61
|
+
assert.equal(r.tool, 'codex');
|
|
62
|
+
assert.equal(r.cwd, '/Users/me/other');
|
|
63
|
+
assert.equal(r.totalTurns, 1); // "ok" is too short to count; the duplicate is folded
|
|
64
|
+
assert.match(r.text, /## User \[2026-09-01T11:00:01\]\nsummarize the repo\n\n## Assistant\nIt is a CLI\./);
|
|
65
|
+
assert.equal(await codexRolloutId(file), 'abc-123');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('handoff prompt names the file, its size, and the source tool', () => {
|
|
69
|
+
const p = handoffPrompt('/x/handoff.md', 5 * 1024, 'claude');
|
|
70
|
+
assert.match(p, /Read the ENTIRE file \/x\/handoff\.md \(5KB\)/);
|
|
71
|
+
assert.match(p, /previous claude conversation/);
|
|
72
|
+
});
|