@adrive/cli 0.3.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -12
- package/dist/commands/auth-login.js +9 -4
- package/dist/commands/context.js +12 -1
- package/dist/commands/hook.js +152 -0
- package/dist/commands/init.js +106 -74
- package/dist/commands/resume.js +41 -0
- package/dist/commands/sync.js +24 -0
- package/dist/commands/timeline.js +65 -0
- package/dist/index.js +35 -20
- package/dist/lib/api-client.js +32 -1
- package/dist/lib/auto-context.js +36 -53
- package/dist/lib/config.js +12 -0
- package/dist/lib/consent.js +60 -0
- package/dist/lib/detect-agents.js +73 -153
- package/dist/lib/harness-commands.js +63 -0
- package/dist/lib/harness-wiring.js +193 -0
- package/dist/lib/ignore.js +24 -4
- package/dist/lib/session-cwd.js +33 -0
- package/dist/lib/skill.js +58 -0
- package/dist/lib/sync.js +433 -0
- package/dist/lib/transcripts/claude.js +86 -0
- package/dist/lib/transcripts/codex.js +130 -0
- package/dist/lib/transcripts/cursor.js +73 -0
- package/dist/lib/transcripts/gemini.js +116 -0
- package/dist/lib/transcripts/opencode.js +95 -0
- package/dist/lib/transcripts/types.js +10 -0
- package/dist/lib/transcripts/util.js +102 -0
- package/dist/lib/write-file.js +55 -0
- package/package.json +12 -11
- package/dist/commands/transcripts.js +0 -210
package/dist/lib/sync.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { projectKeyFor, scopeProjectKey } from '@adrive/mcp-server/project';
|
|
7
|
+
import { renderSession, sessionPath, sessionRunId } from '@adrive/mcp-server/session';
|
|
8
|
+
import { CliApiClient } from './api-client.js';
|
|
9
|
+
import { configDir as defaultConfigDir, readConfig } from './config.js';
|
|
10
|
+
import { encryptContentAsync } from './crypto.js';
|
|
11
|
+
import { DEFAULT_DENY, compileRules, loadRules, shouldIgnore } from './ignore.js';
|
|
12
|
+
import { redact, summarizeHits } from './redactor.js';
|
|
13
|
+
import { readSessionCwds } from './session-cwd.js';
|
|
14
|
+
import { claudeSource } from './transcripts/claude.js';
|
|
15
|
+
import { codexSource } from './transcripts/codex.js';
|
|
16
|
+
import { cursorSource } from './transcripts/cursor.js';
|
|
17
|
+
import { geminiSource } from './transcripts/gemini.js';
|
|
18
|
+
import { opencodeSource } from './transcripts/opencode.js';
|
|
19
|
+
import { mtimeOf } from './transcripts/util.js';
|
|
20
|
+
export const HARNESSES = ['claude-code', 'codex', 'opencode', 'gemini-cli', 'cursor'];
|
|
21
|
+
const SOURCES = {
|
|
22
|
+
'claude-code': claudeSource,
|
|
23
|
+
codex: codexSource,
|
|
24
|
+
opencode: opencodeSource,
|
|
25
|
+
'gemini-cli': geminiSource,
|
|
26
|
+
cursor: cursorSource,
|
|
27
|
+
};
|
|
28
|
+
const DEBOUNCE_MS = 120_000;
|
|
29
|
+
const FIRST_SWEEP_MS = 14 * 24 * 60 * 60_000;
|
|
30
|
+
const SWEEP_CAP = 50;
|
|
31
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
32
|
+
// Hook events fire in bursts; a detached sync waits its turn instead of dropping.
|
|
33
|
+
const LOCK_WAIT_MS = 30_000;
|
|
34
|
+
const LOCK_POLL_MS = 300;
|
|
35
|
+
const LOG_CAP_BYTES = 1024 * 1024;
|
|
36
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
37
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
38
|
+
const MAX_PATHS_TOUCHED = 200;
|
|
39
|
+
// A swept session untouched this long is treated as finished.
|
|
40
|
+
const IDLE_MS = 30 * 60_000;
|
|
41
|
+
function sha256(data) {
|
|
42
|
+
return createHash('sha256').update(data).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
function toPosix(p) {
|
|
45
|
+
return p.split(sep).join('/');
|
|
46
|
+
}
|
|
47
|
+
function isInside(root, p) {
|
|
48
|
+
const rel = relative(root, p);
|
|
49
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
50
|
+
}
|
|
51
|
+
const MAX_REMOTE_CHARS = 500;
|
|
52
|
+
// Worker paths allow only [A-Za-z0-9._-/] with no '..' or '//'. When cleaning
|
|
53
|
+
// or truncating changes `rel`, a hash of the original keeps distinct files apart.
|
|
54
|
+
export function safeRemote(prefix, rel) {
|
|
55
|
+
const clean = rel
|
|
56
|
+
.split('/')
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((s) => s.replace(/[^A-Za-z0-9._-]/g, '-').replace(/\.{2,}/g, '.'))
|
|
59
|
+
.join('/');
|
|
60
|
+
const full = `${prefix}/${clean}`;
|
|
61
|
+
if (clean === rel && full.length <= MAX_REMOTE_CHARS)
|
|
62
|
+
return full;
|
|
63
|
+
const tag = `-${sha256(rel).slice(0, 8)}`;
|
|
64
|
+
const cut = full.slice(0, MAX_REMOTE_CHARS - tag.length);
|
|
65
|
+
const dot = cut.lastIndexOf('.');
|
|
66
|
+
const at = dot > cut.lastIndexOf('/') + 1 ? dot : cut.length;
|
|
67
|
+
return `${cut.slice(0, at)}${tag}${cut.slice(at)}`;
|
|
68
|
+
}
|
|
69
|
+
async function realpathSafe(p) {
|
|
70
|
+
try {
|
|
71
|
+
return await fs.realpath(p);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Only untracked, non-ignored files (or files outside any repo) may upload:
|
|
78
|
+
// tracked files are already in git, and gitignored ones are where secrets live.
|
|
79
|
+
// Fails closed: git also exits 128 for "dubious ownership" (WSL, bind mounts),
|
|
80
|
+
// so only an explicit "not a git repository" counts as outside a repo.
|
|
81
|
+
function gitState(file) {
|
|
82
|
+
return new Promise((done) => {
|
|
83
|
+
execFile('git', ['-C', dirname(file), 'status', '--porcelain', '--ignored', '--untracked-files=all', '--', basename(file)], { timeout: 2000, env: { ...process.env, LC_ALL: 'C' } }, (err, stdout, stderr) => {
|
|
84
|
+
if (err)
|
|
85
|
+
return done(/not a git repository/i.test(String(stderr)) ? 'no-repo' : 'skip');
|
|
86
|
+
done(stdout.startsWith('??') ? 'untracked' : 'skip');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function readManifest(path) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(await fs.readFile(path, 'utf8'));
|
|
93
|
+
return { scope: parsed.scope, sessions: parsed.sessions ?? {}, files: parsed.files ?? {}, last_sweep_at: parsed.last_sweep_at };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { sessions: {}, files: {} };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function writeManifest(path, m) {
|
|
100
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
101
|
+
await fs.writeFile(tmp, JSON.stringify(m), { encoding: 'utf8', mode: 0o600 });
|
|
102
|
+
await fs.rename(tmp, path);
|
|
103
|
+
}
|
|
104
|
+
async function acquireLock(path, waitMs) {
|
|
105
|
+
const deadline = Date.now() + waitMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
try {
|
|
108
|
+
const handle = await fs.open(path, 'wx', 0o600);
|
|
109
|
+
await handle.writeFile(String(process.pid));
|
|
110
|
+
await handle.close();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err.code !== 'EEXIST')
|
|
115
|
+
throw err;
|
|
116
|
+
const st = await fs.stat(path).catch(() => null);
|
|
117
|
+
if (!st)
|
|
118
|
+
continue;
|
|
119
|
+
if (Date.now() - st.mtimeMs >= LOCK_STALE_MS) {
|
|
120
|
+
await fs.rm(path, { force: true });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (Date.now() >= deadline)
|
|
124
|
+
return false;
|
|
125
|
+
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function makeLogger(quiet, dir) {
|
|
130
|
+
if (!quiet)
|
|
131
|
+
return async (msg) => void process.stdout.write(`${msg}\n`);
|
|
132
|
+
const path = join(dir, 'sync.log');
|
|
133
|
+
return async (msg) => {
|
|
134
|
+
try {
|
|
135
|
+
const st = await fs.stat(path).catch(() => null);
|
|
136
|
+
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
137
|
+
if (st && st.size > LOG_CAP_BYTES)
|
|
138
|
+
await fs.writeFile(path, line, { mode: 0o600 });
|
|
139
|
+
else
|
|
140
|
+
await fs.appendFile(path, line, { mode: 0o600 });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Logging must never break a hook-spawned sync.
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function defaultClient(cfg, harness) {
|
|
148
|
+
return new CliApiClient({ baseUrl: cfg.api_url, apiKey: cfg.api_key, agentPlatform: harness, agentRole: cfg.agent_role });
|
|
149
|
+
}
|
|
150
|
+
// shortcut: no trailing-edge debounce; the last turn of a session without an end hook syncs on the next sweep from any harness.
|
|
151
|
+
export async function runSync(opts = {}) {
|
|
152
|
+
const result = { synced: [], unchanged: 0, debounced: 0, failed: 0, perHarness: {} };
|
|
153
|
+
const dir = opts.configDir ?? defaultConfigDir();
|
|
154
|
+
const home = opts.home ?? homedir();
|
|
155
|
+
const now = opts.now ?? Date.now();
|
|
156
|
+
const dryRun = !!opts.dryRun;
|
|
157
|
+
const log = makeLogger(!!opts.quiet, dir);
|
|
158
|
+
const sources = { ...SOURCES, ...opts.sources };
|
|
159
|
+
const cfg = await readConfig(join(dir, 'config.json'));
|
|
160
|
+
if (!dryRun && !cfg?.api_key) {
|
|
161
|
+
await log('sync skipped: not logged in');
|
|
162
|
+
return { ...result, skipped: 'not-logged-in' };
|
|
163
|
+
}
|
|
164
|
+
const captureTranscripts = dryRun || !!cfg?.preferences?.capture_transcripts;
|
|
165
|
+
const captureArtifacts = dryRun || !!cfg?.preferences?.capture_artifacts;
|
|
166
|
+
if (!captureTranscripts && !captureArtifacts)
|
|
167
|
+
return { ...result, skipped: 'capture-off' };
|
|
168
|
+
const lockPath = join(dir, 'sync.lock');
|
|
169
|
+
if (!dryRun)
|
|
170
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
171
|
+
if (!dryRun && !(await acquireLock(lockPath, opts.lockWaitMs ?? LOCK_WAIT_MS))) {
|
|
172
|
+
await log('sync skipped: another sync holds the lock');
|
|
173
|
+
return { ...result, skipped: 'locked' };
|
|
174
|
+
}
|
|
175
|
+
const manifestPath = join(dir, 'sync.manifest.json');
|
|
176
|
+
let manifest = await readManifest(manifestPath);
|
|
177
|
+
try {
|
|
178
|
+
let workspaceId;
|
|
179
|
+
if (!dryRun) {
|
|
180
|
+
workspaceId = cfg.default_workspace;
|
|
181
|
+
if (!workspaceId) {
|
|
182
|
+
const list = await (opts.clientFor ?? defaultClient)(cfg, opts.harness ?? HARNESSES[0]).listWorkspaces();
|
|
183
|
+
if (list.length === 1)
|
|
184
|
+
workspaceId = list[0].id;
|
|
185
|
+
else {
|
|
186
|
+
await log(`sync skipped: ${list.length} workspaces and no default_workspace set`);
|
|
187
|
+
return { ...result, skipped: 'no-workspace' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Synced state and file ids only hold for the account, workspace and
|
|
191
|
+
// privacy mode (which changes project keys) they were written under; any
|
|
192
|
+
// switch starts over like a first sweep.
|
|
193
|
+
const scope = sha256(`${cfg.api_url}\n${cfg.api_key}\n${workspaceId}\n${!!cfg.preferences?.private_workspace}`);
|
|
194
|
+
if (manifest.scope !== scope)
|
|
195
|
+
manifest = { scope, sessions: {}, files: {} };
|
|
196
|
+
}
|
|
197
|
+
const candidates = [];
|
|
198
|
+
const named = !!opts.harness && !!(opts.session || opts.transcript);
|
|
199
|
+
if (named) {
|
|
200
|
+
const entry = await locate(sources[opts.harness], opts, home, dir);
|
|
201
|
+
if (entry)
|
|
202
|
+
candidates.push({ harness: opts.harness, entry, explicit: true });
|
|
203
|
+
else
|
|
204
|
+
await log(`${opts.harness} session ${opts.session ?? opts.transcript} not found`);
|
|
205
|
+
}
|
|
206
|
+
const sweep = !named || !!opts.all || !!opts.start || !!opts.force || !manifest.last_sweep_at || now - manifest.last_sweep_at > DEBOUNCE_MS;
|
|
207
|
+
let deferredMin;
|
|
208
|
+
const defer = (e) => {
|
|
209
|
+
deferredMin = Math.min(deferredMin ?? Infinity, e.mtime.getTime());
|
|
210
|
+
};
|
|
211
|
+
if (sweep) {
|
|
212
|
+
const since = opts.all ? undefined : new Date(manifest.last_sweep_at ?? now - FIRST_SWEEP_MS);
|
|
213
|
+
const found = [];
|
|
214
|
+
for (const harness of opts.harness && !named ? [opts.harness] : HARNESSES) {
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = await sources[harness].enumerate({ home, since, configDir: dir, cwd: opts.cwd ?? process.cwd() });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
await log(`${harness} enumerate failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
if (candidates.some((c) => c.harness === harness && c.entry.sessionId === entry.sessionId))
|
|
225
|
+
continue;
|
|
226
|
+
const prev = manifest.sessions[sessionRunId(harness, entry.sessionId)];
|
|
227
|
+
if (prev && prev.synced_at >= entry.mtime.getTime())
|
|
228
|
+
continue;
|
|
229
|
+
found.push({ harness, entry, explicit: false });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Sessions with no end event (OpenCode always, or a killed agent) never
|
|
233
|
+
// change again, so a stale in_progress entry is revisited to finalize it.
|
|
234
|
+
for (const [runId, rec] of Object.entries(manifest.sessions)) {
|
|
235
|
+
if (rec.status !== 'in_progress' || !rec.harness || !rec.path || now - rec.synced_at < DEBOUNCE_MS)
|
|
236
|
+
continue;
|
|
237
|
+
if (opts.harness && !named && rec.harness !== opts.harness)
|
|
238
|
+
continue;
|
|
239
|
+
const sessionId = runId.slice(rec.harness.length + 1);
|
|
240
|
+
const seen = (c) => c.harness === rec.harness && c.entry.sessionId === sessionId;
|
|
241
|
+
if (candidates.some(seen) || found.some(seen))
|
|
242
|
+
continue;
|
|
243
|
+
const mtime = (await mtimeOf(rec.path)) ?? (rec.mtime ? new Date(rec.mtime) : null);
|
|
244
|
+
if (!mtime || now - mtime.getTime() <= IDLE_MS)
|
|
245
|
+
continue;
|
|
246
|
+
found.push({ harness: rec.harness, entry: { sessionId, path: rec.path, mtime, cwd: rec.cwd }, explicit: false });
|
|
247
|
+
}
|
|
248
|
+
found.sort((a, b) => a.entry.mtime.getTime() - b.entry.mtime.getTime());
|
|
249
|
+
for (const c of found.slice(SWEEP_CAP))
|
|
250
|
+
defer(c.entry);
|
|
251
|
+
candidates.push(...found.slice(0, SWEEP_CAP));
|
|
252
|
+
}
|
|
253
|
+
const ctx = {
|
|
254
|
+
opts,
|
|
255
|
+
cfg: cfg,
|
|
256
|
+
manifest,
|
|
257
|
+
sources,
|
|
258
|
+
workspaceId,
|
|
259
|
+
now,
|
|
260
|
+
dryRun,
|
|
261
|
+
captureTranscripts,
|
|
262
|
+
captureArtifacts,
|
|
263
|
+
realHome: (await realpathSafe(home)) ?? home,
|
|
264
|
+
cwdRules: new Map(),
|
|
265
|
+
sessionCwds: await readSessionCwds(dir),
|
|
266
|
+
log,
|
|
267
|
+
};
|
|
268
|
+
for (const c of candidates) {
|
|
269
|
+
try {
|
|
270
|
+
const outcome = await syncSession(ctx, c, result);
|
|
271
|
+
if (outcome === 'debounced') {
|
|
272
|
+
result.debounced++;
|
|
273
|
+
defer(c.entry);
|
|
274
|
+
}
|
|
275
|
+
else if (outcome === 'unchanged') {
|
|
276
|
+
result.unchanged++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
result.failed++;
|
|
281
|
+
defer(c.entry);
|
|
282
|
+
await log(`${c.harness} ${c.entry.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (sweep && !dryRun)
|
|
286
|
+
manifest.last_sweep_at = deferredMin === undefined ? now : Math.min(now, deferredMin - 1);
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
if (!dryRun) {
|
|
291
|
+
await writeManifest(manifestPath, manifest).catch(() => undefined);
|
|
292
|
+
await fs.rm(lockPath, { force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function locate(source, opts, home, dir) {
|
|
297
|
+
if (opts.transcript) {
|
|
298
|
+
const mtime = await mtimeOf(opts.transcript);
|
|
299
|
+
if (!mtime)
|
|
300
|
+
return null;
|
|
301
|
+
const sessionId = opts.session ?? basename(opts.transcript).replace(/\.jsonl?(\.zst)?$/, '');
|
|
302
|
+
return { sessionId, path: opts.transcript, mtime, cwd: opts.cwd };
|
|
303
|
+
}
|
|
304
|
+
const entries = await source.enumerate({ home, configDir: dir, cwd: opts.cwd });
|
|
305
|
+
const hit = entries.find((e) => e.sessionId === opts.session);
|
|
306
|
+
return hit ? { ...hit, cwd: hit.cwd ?? opts.cwd } : null;
|
|
307
|
+
}
|
|
308
|
+
const homeRules = compileRules(DEFAULT_DENY);
|
|
309
|
+
async function syncSession(ctx, c, result) {
|
|
310
|
+
const { opts, manifest, now } = ctx;
|
|
311
|
+
if (!opts.force && now - (manifest.sessions[sessionRunId(c.harness, c.entry.sessionId)]?.synced_at ?? 0) < DEBOUNCE_MS) {
|
|
312
|
+
return 'debounced';
|
|
313
|
+
}
|
|
314
|
+
const doc = await ctx.sources[c.harness].parseSession(c.entry);
|
|
315
|
+
if (!doc)
|
|
316
|
+
return 'empty';
|
|
317
|
+
const stats = (result.perHarness[c.harness] ??= { parsed: 0, files: 0 });
|
|
318
|
+
stats.parsed++;
|
|
319
|
+
const sessionId = doc.entry.sessionId;
|
|
320
|
+
const runId = sessionRunId(c.harness, sessionId);
|
|
321
|
+
const prev = manifest.sessions[runId];
|
|
322
|
+
const cwd = doc.cwd ?? prev?.cwd ?? ctx.sessionCwds[runId];
|
|
323
|
+
const privateWorkspace = !!ctx.cfg?.preferences?.private_workspace;
|
|
324
|
+
const project = cwd ? scopeProjectKey(projectKeyFor(cwd), privateWorkspace) : prev?.project ?? scopeProjectKey('local__unknown', privateWorkspace);
|
|
325
|
+
const status = c.explicit
|
|
326
|
+
? opts.force ? 'success' : 'in_progress'
|
|
327
|
+
: now - doc.entry.mtime.getTime() > IDLE_MS ? 'success' : 'in_progress';
|
|
328
|
+
const absPaths = [...new Set(doc.filesTouched.map((p) => (isAbsolute(p) ? p : cwd ? resolve(cwd, p) : null)).filter((p) => !!p))];
|
|
329
|
+
const display = (abs) => (cwd && isInside(cwd, abs) ? toPosix(relative(cwd, abs)) : abs);
|
|
330
|
+
const pathsTouched = absPaths.map(display);
|
|
331
|
+
const updatedAt = [...doc.turns].reverse().find((t) => t.ts)?.ts ?? doc.entry.mtime.toISOString();
|
|
332
|
+
const lastTurnAt = Date.parse(updatedAt);
|
|
333
|
+
const lastActivityAt = Number.isFinite(lastTurnAt) ? lastTurnAt : doc.entry.mtime.getTime();
|
|
334
|
+
const markdown = renderSession({
|
|
335
|
+
harness: c.harness,
|
|
336
|
+
sessionId,
|
|
337
|
+
project,
|
|
338
|
+
cwd,
|
|
339
|
+
title: doc.title,
|
|
340
|
+
updatedAt,
|
|
341
|
+
turns: doc.turns,
|
|
342
|
+
filesTouched: pathsTouched,
|
|
343
|
+
});
|
|
344
|
+
const hash = sha256(markdown);
|
|
345
|
+
if (prev && prev.hash === hash && prev.status === status)
|
|
346
|
+
return 'unchanged';
|
|
347
|
+
const encrypt = !ctx.dryRun && privateWorkspace;
|
|
348
|
+
const seal = async (text) => {
|
|
349
|
+
const { redacted, hits } = redact(text);
|
|
350
|
+
return { body: encrypt ? await encryptContentAsync(ctx.cfg.api_key, redacted) : redacted, tag: summarizeHits(hits) };
|
|
351
|
+
};
|
|
352
|
+
const client = ctx.dryRun ? null : (opts.clientFor ?? defaultClient)(ctx.cfg, c.harness);
|
|
353
|
+
let transcriptPath;
|
|
354
|
+
if (ctx.captureTranscripts) {
|
|
355
|
+
transcriptPath = sessionPath(project, c.harness, sessionId);
|
|
356
|
+
if (client && prev?.hash !== hash) {
|
|
357
|
+
const { body, tag } = await seal(markdown);
|
|
358
|
+
await client.writeFile(ctx.workspaceId, transcriptPath, body, `session sync (${tag})`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const artifactIds = [];
|
|
362
|
+
let wouldUpload = 0;
|
|
363
|
+
if (ctx.captureArtifacts) {
|
|
364
|
+
const realCwd = cwd ? await realpathSafe(cwd) : null;
|
|
365
|
+
for (const abs of absPaths) {
|
|
366
|
+
const real = await realpathSafe(abs);
|
|
367
|
+
if (!real || shouldIgnore(ctx.realHome, real, homeRules))
|
|
368
|
+
continue;
|
|
369
|
+
const inCwd = !!realCwd && isInside(realCwd, real);
|
|
370
|
+
if (inCwd) {
|
|
371
|
+
let rules = ctx.cwdRules.get(realCwd);
|
|
372
|
+
if (!rules)
|
|
373
|
+
ctx.cwdRules.set(realCwd, (rules = await loadRules(realCwd, ['.adriveignore'])));
|
|
374
|
+
if (shouldIgnore(realCwd, real, rules))
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const st = await fs.stat(real);
|
|
378
|
+
if (!st.isFile() || st.size > MAX_FILE_BYTES)
|
|
379
|
+
continue;
|
|
380
|
+
if ((await gitState(real)) === 'skip')
|
|
381
|
+
continue;
|
|
382
|
+
const buf = await fs.readFile(real);
|
|
383
|
+
if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
|
|
384
|
+
continue;
|
|
385
|
+
const fileHash = sha256(buf);
|
|
386
|
+
const known = manifest.files[real];
|
|
387
|
+
if (known?.hash === fileHash) {
|
|
388
|
+
artifactIds.push(known.id);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const rel = inCwd ? toPosix(relative(realCwd, real)) : `_external/${sha256(real).slice(0, 8)}-${basename(real)}`;
|
|
392
|
+
// Content is sealed in a private workspace, but a path would still name the file.
|
|
393
|
+
const remote = privateWorkspace ? `sessions/${project}/files/${sha256(rel).slice(0, 16)}` : safeRemote(`sessions/${project}/files`, rel);
|
|
394
|
+
if (!client) {
|
|
395
|
+
wouldUpload++;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const { body, tag } = await seal(buf.toString('utf8'));
|
|
399
|
+
const res = await client.writeFile(ctx.workspaceId, remote, body, `session artifact from ${c.harness} (${tag})`);
|
|
400
|
+
manifest.files[real] = { hash: fileHash, id: res.id };
|
|
401
|
+
artifactIds.push(res.id);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
stats.files += client ? artifactIds.length : wouldUpload;
|
|
405
|
+
if (client) {
|
|
406
|
+
// Run metadata is plaintext, so a private workspace keeps the title, cwd and
|
|
407
|
+
// paths out of it entirely; otherwise the title is redacted before it leaves.
|
|
408
|
+
const title = doc.title && !encrypt ? redact(doc.title).redacted : undefined;
|
|
409
|
+
await client.logRun({
|
|
410
|
+
run_id: runId,
|
|
411
|
+
task: title || (encrypt ? 'Private session' : 'Untitled session'),
|
|
412
|
+
status,
|
|
413
|
+
workspace_id: ctx.workspaceId,
|
|
414
|
+
artifacts_produced: artifactIds,
|
|
415
|
+
metadata: {
|
|
416
|
+
project,
|
|
417
|
+
cwd: encrypt ? undefined : cwd,
|
|
418
|
+
title,
|
|
419
|
+
transcript_path: transcriptPath,
|
|
420
|
+
files_uploaded: artifactIds.length,
|
|
421
|
+
paths_touched: encrypt ? [] : pathsTouched.slice(0, MAX_PATHS_TOUCHED),
|
|
422
|
+
turns: doc.turns.length,
|
|
423
|
+
harness_session_id: sessionId,
|
|
424
|
+
last_activity_at: lastActivityAt,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
manifest.sessions[runId] = { hash, status, synced_at: now, project, cwd, harness: c.harness, path: doc.entry.path, mtime: doc.entry.mtime.getTime() };
|
|
428
|
+
}
|
|
429
|
+
result.synced.push(runId);
|
|
430
|
+
await ctx.log(`${ctx.dryRun ? '[dry] ' : ''}${runId}: ${doc.turns.length} turns, ${pathsTouched.length} paths touched, ` +
|
|
431
|
+
`${client ? artifactIds.length : wouldUpload} files ${client ? 'linked' : 'would upload'} -> ${project}`);
|
|
432
|
+
return 'synced';
|
|
433
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, readText, toolSummary } from './util.js';
|
|
4
|
+
// Claude Code: ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl. Lines of
|
|
5
|
+
// type user|assistant carry `message.content` (string or parts: text,
|
|
6
|
+
// tool_use, tool_result) plus `cwd` and `timestamp`.
|
|
7
|
+
const FILE_TOOLS = {
|
|
8
|
+
Write: 'file_path',
|
|
9
|
+
Edit: 'file_path',
|
|
10
|
+
MultiEdit: 'file_path',
|
|
11
|
+
NotebookEdit: 'notebook_path',
|
|
12
|
+
};
|
|
13
|
+
export const claudeSource = {
|
|
14
|
+
platform: 'claude-code',
|
|
15
|
+
async enumerate({ home, since }) {
|
|
16
|
+
const projectsDir = join(home, '.claude', 'projects');
|
|
17
|
+
let projects;
|
|
18
|
+
try {
|
|
19
|
+
projects = await fs.readdir(projectsDir);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const p of projects) {
|
|
26
|
+
let files;
|
|
27
|
+
try {
|
|
28
|
+
files = await fs.readdir(join(projectsDir, p));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
if (!f.endsWith('.jsonl'))
|
|
35
|
+
continue;
|
|
36
|
+
const path = join(projectsDir, p, f);
|
|
37
|
+
const mtime = await mtimeOf(path);
|
|
38
|
+
if (!mtime || (since && mtime <= since))
|
|
39
|
+
continue;
|
|
40
|
+
out.push({ sessionId: f.slice(0, -'.jsonl'.length), path, mtime });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
44
|
+
},
|
|
45
|
+
async parseSession(entry) {
|
|
46
|
+
const raw = await readText(entry.path);
|
|
47
|
+
if (raw === null)
|
|
48
|
+
return null;
|
|
49
|
+
const doc = new DocBuilder();
|
|
50
|
+
let cwd;
|
|
51
|
+
for (const obj of jsonLines(raw)) {
|
|
52
|
+
cwd ??= asStr(obj.cwd);
|
|
53
|
+
if ((obj.type !== 'user' && obj.type !== 'assistant') || obj.isMeta)
|
|
54
|
+
continue;
|
|
55
|
+
const msg = asObj(obj.message);
|
|
56
|
+
if (!msg)
|
|
57
|
+
continue;
|
|
58
|
+
const role = asStr(msg.role) ?? String(obj.type);
|
|
59
|
+
let text = '';
|
|
60
|
+
const tools = [];
|
|
61
|
+
if (typeof msg.content === 'string') {
|
|
62
|
+
text = msg.content;
|
|
63
|
+
}
|
|
64
|
+
else if (Array.isArray(msg.content)) {
|
|
65
|
+
for (const part of msg.content) {
|
|
66
|
+
const p = asObj(part);
|
|
67
|
+
if (!p)
|
|
68
|
+
continue;
|
|
69
|
+
if (p.type === 'tool_use') {
|
|
70
|
+
const name = asStr(p.name) ?? 'tool';
|
|
71
|
+
const input = asObj(p.input);
|
|
72
|
+
const pathKey = FILE_TOOLS[name];
|
|
73
|
+
const path = pathKey ? asStr(input?.[pathKey]) : undefined;
|
|
74
|
+
doc.touch(path);
|
|
75
|
+
tools.push(path ? { name, path } : { name, summary: toolSummary(input) });
|
|
76
|
+
}
|
|
77
|
+
else if (typeof p.text === 'string' && p.type !== 'tool_result') {
|
|
78
|
+
text += p.text;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
doc.addTurn(role, text, asStr(obj.timestamp), tools);
|
|
83
|
+
}
|
|
84
|
+
return doc.build(entry, cwd);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, patchPaths, readText, toolSummary } from './util.js';
|
|
4
|
+
// Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, with
|
|
5
|
+
// older rollouts zstd-compressed to .jsonl.zst. Lines are
|
|
6
|
+
// `{ timestamp, type, payload }`: session_meta (id, cwd), response_item
|
|
7
|
+
// (message | function_call | custom_tool_call), and event_msg
|
|
8
|
+
// item_completed whose FileChange item is the 0.160 file-edit signal. The
|
|
9
|
+
// format shifts between CLI versions, so unknown records are ignored.
|
|
10
|
+
const ROLLOUT_RE = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i;
|
|
11
|
+
// Codex injects environment/instruction dumps as user messages; a part that
|
|
12
|
+
// is one whole <tag>...</tag> block is harness boilerplate, not the user.
|
|
13
|
+
const BOILERPLATE_RE = /^\s*(?:<([a-z][\w-]*)[\s>][\s\S]*<\/\1>\s*$|# AGENTS\.md instructions)/i;
|
|
14
|
+
async function readdirSafe(dir) {
|
|
15
|
+
try {
|
|
16
|
+
return await fs.readdir(dir);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function walkRollouts(root) {
|
|
23
|
+
const acc = [];
|
|
24
|
+
for (const y of await readdirSafe(root)) {
|
|
25
|
+
for (const m of await readdirSafe(join(root, y))) {
|
|
26
|
+
for (const d of await readdirSafe(join(root, y, m))) {
|
|
27
|
+
const dir = join(root, y, m, d);
|
|
28
|
+
for (const f of await readdirSafe(dir)) {
|
|
29
|
+
if (ROLLOUT_RE.test(f))
|
|
30
|
+
acc.push(join(dir, f));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return acc;
|
|
36
|
+
}
|
|
37
|
+
function parseArgs(raw) {
|
|
38
|
+
if (typeof raw !== 'string')
|
|
39
|
+
return raw;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const codexSource = {
|
|
48
|
+
platform: 'codex',
|
|
49
|
+
async enumerate({ home, since }) {
|
|
50
|
+
const byId = new Map();
|
|
51
|
+
for (const path of await walkRollouts(join(home, '.codex', 'sessions'))) {
|
|
52
|
+
const mtime = await mtimeOf(path);
|
|
53
|
+
if (!mtime || (since && mtime <= since))
|
|
54
|
+
continue;
|
|
55
|
+
const sessionId = ROLLOUT_RE.exec(path.split('/').pop())[1];
|
|
56
|
+
const prev = byId.get(sessionId);
|
|
57
|
+
if (!prev || prev.mtime < mtime)
|
|
58
|
+
byId.set(sessionId, { sessionId, path, mtime });
|
|
59
|
+
}
|
|
60
|
+
return [...byId.values()].sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
61
|
+
},
|
|
62
|
+
async parseSession(entry) {
|
|
63
|
+
const raw = await readText(entry.path);
|
|
64
|
+
if (raw === null)
|
|
65
|
+
return null;
|
|
66
|
+
const doc = new DocBuilder();
|
|
67
|
+
let cwd;
|
|
68
|
+
let metaId;
|
|
69
|
+
for (const obj of jsonLines(raw)) {
|
|
70
|
+
const ts = asStr(obj.timestamp);
|
|
71
|
+
const payload = asObj(obj.payload) ?? {};
|
|
72
|
+
// Forked rollouts repeat their parents' session_meta after their own.
|
|
73
|
+
if (obj.type === 'session_meta') {
|
|
74
|
+
cwd ??= asStr(payload.cwd);
|
|
75
|
+
metaId ??= asStr(payload.id);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (obj.type === 'event_msg' && payload.type === 'item_completed') {
|
|
79
|
+
const item = asObj(payload.item);
|
|
80
|
+
const changes = item?.type === 'FileChange' ? asObj(item.changes) : undefined;
|
|
81
|
+
if (changes) {
|
|
82
|
+
const tools = Object.keys(changes).map((path) => {
|
|
83
|
+
doc.touch(path);
|
|
84
|
+
return { name: `file_${asStr(asObj(changes[path])?.type) ?? 'change'}`, path };
|
|
85
|
+
});
|
|
86
|
+
doc.addTurn('assistant', '', ts, tools);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (obj.type !== 'response_item' && obj.type !== 'turn')
|
|
91
|
+
continue;
|
|
92
|
+
if (payload.type === 'function_call') {
|
|
93
|
+
doc.addTurn('assistant', '', ts, [{ name: asStr(payload.name) ?? 'tool', summary: toolSummary(parseArgs(payload.arguments)) }]);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (payload.type === 'custom_tool_call') {
|
|
97
|
+
const name = asStr(payload.name) ?? 'tool';
|
|
98
|
+
const input = asStr(payload.input) ?? '';
|
|
99
|
+
if (name === 'apply_patch') {
|
|
100
|
+
const paths = patchPaths(input);
|
|
101
|
+
for (const p of paths)
|
|
102
|
+
doc.touch(p);
|
|
103
|
+
doc.addTurn('assistant', '', ts, paths.map((path) => ({ name, path })));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
doc.addTurn('assistant', '', ts, [{ name, summary: toolSummary(input) }]);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const role = asStr(payload.role);
|
|
111
|
+
if (role !== 'user' && role !== 'assistant')
|
|
112
|
+
continue;
|
|
113
|
+
if (payload.type !== 'message' && obj.type !== 'turn')
|
|
114
|
+
continue;
|
|
115
|
+
let text = '';
|
|
116
|
+
if (typeof payload.content === 'string') {
|
|
117
|
+
text = BOILERPLATE_RE.test(payload.content) ? '' : payload.content;
|
|
118
|
+
}
|
|
119
|
+
else if (Array.isArray(payload.content)) {
|
|
120
|
+
for (const part of payload.content) {
|
|
121
|
+
const t = asStr(asObj(part)?.text);
|
|
122
|
+
if (t && !BOILERPLATE_RE.test(t))
|
|
123
|
+
text += t;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
doc.addTurn(role, text, ts);
|
|
127
|
+
}
|
|
128
|
+
return doc.build(metaId ? { ...entry, sessionId: metaId } : entry, cwd);
|
|
129
|
+
},
|
|
130
|
+
};
|