@adrive/cli 1.0.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 +10 -11
- package/dist/commands/auth-login.js +9 -4
- package/dist/commands/context.js +0 -6
- package/dist/commands/hook.js +152 -0
- package/dist/commands/init.js +106 -82
- package/dist/commands/resume.js +41 -0
- package/dist/commands/sync.js +24 -0
- package/dist/index.js +26 -21
- package/dist/lib/api-client.js +17 -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 +41 -100
- package/dist/lib/sync.js +433 -0
- package/dist/lib/transcripts/claude.js +51 -77
- package/dist/lib/transcripts/codex.js +95 -133
- package/dist/lib/transcripts/cursor.js +68 -18
- package/dist/lib/transcripts/gemini.js +116 -0
- package/dist/lib/transcripts/opencode.js +95 -0
- package/dist/lib/transcripts/types.js +5 -5
- package/dist/lib/transcripts/util.js +102 -0
- package/dist/lib/write-file.js +55 -0
- package/package.json +11 -10
- package/dist/commands/transcripts.js +0 -56
- package/dist/lib/transcripts/ingest.js +0 -115
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
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
};
|
|
11
13
|
export const claudeSource = {
|
|
12
14
|
platform: 'claude-code',
|
|
13
15
|
async enumerate({ home, since }) {
|
|
@@ -21,10 +23,9 @@ export const claudeSource = {
|
|
|
21
23
|
}
|
|
22
24
|
const out = [];
|
|
23
25
|
for (const p of projects) {
|
|
24
|
-
const pdir = join(projectsDir, p);
|
|
25
26
|
let files;
|
|
26
27
|
try {
|
|
27
|
-
files = await fs.readdir(
|
|
28
|
+
files = await fs.readdir(join(projectsDir, p));
|
|
28
29
|
}
|
|
29
30
|
catch {
|
|
30
31
|
continue;
|
|
@@ -32,81 +33,54 @@ export const claudeSource = {
|
|
|
32
33
|
for (const f of files) {
|
|
33
34
|
if (!f.endsWith('.jsonl'))
|
|
34
35
|
continue;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
stat = await fs.stat(full);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
36
|
+
const path = join(projectsDir, p, f);
|
|
37
|
+
const mtime = await mtimeOf(path);
|
|
38
|
+
if (!mtime || (since && mtime <= since))
|
|
41
39
|
continue;
|
|
42
|
-
}
|
|
43
|
-
if (since && stat.mtime <= since)
|
|
44
|
-
continue;
|
|
45
|
-
const sessionId = f.replace(/\.jsonl$/, '');
|
|
46
|
-
out.push({
|
|
47
|
-
manifestKey: `claude-code:${sessionId}`,
|
|
48
|
-
sessionId,
|
|
49
|
-
path: full,
|
|
50
|
-
mtime: stat.mtime,
|
|
51
|
-
label: p,
|
|
52
|
-
});
|
|
40
|
+
out.push({ sessionId: f.slice(0, -'.jsonl'.length), path, mtime });
|
|
53
41
|
}
|
|
54
42
|
}
|
|
55
43
|
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
56
44
|
},
|
|
57
45
|
async parseSession(entry) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
raw = await fs.readFile(entry.path, 'utf8');
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
46
|
+
const raw = await readText(entry.path);
|
|
47
|
+
if (raw === null)
|
|
63
48
|
return null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const
|
|
67
|
-
|
|
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)
|
|
68
54
|
continue;
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let text = '';
|
|
95
|
-
if (typeof content === 'string') {
|
|
96
|
-
text = content;
|
|
97
|
-
}
|
|
98
|
-
else if (Array.isArray(content)) {
|
|
99
|
-
for (const part of content) {
|
|
100
|
-
if (typeof part === 'string')
|
|
101
|
-
text += part;
|
|
102
|
-
else if (part && typeof part === 'object' && 'text' in part) {
|
|
103
|
-
text += String(part.text);
|
|
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
|
+
}
|
|
104
80
|
}
|
|
105
81
|
}
|
|
82
|
+
doc.addTurn(role, text, asStr(obj.timestamp), tools);
|
|
106
83
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
84
|
+
return doc.build(entry, cwd);
|
|
85
|
+
},
|
|
86
|
+
};
|