@genspark/opencode-conversation-archiver 0.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/dist/core.js ADDED
@@ -0,0 +1,833 @@
1
+ import { appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir, hostname } from 'node:os';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
8
+ const MAX_SLUG = 60;
9
+ const ARCHIVE_LOCK_WAIT_MS = 15 * 60_000;
10
+ const ARCHIVE_LOCK_STALE_MS = 20 * 60_000;
11
+ const PUSH_LOCK_WAIT_MS = 15 * 60_000;
12
+ const PUSH_LOCK_STALE_MS = 20 * 60_000;
13
+ const URL_CREDENTIAL = /(https?:\/\/)[^/@\s]+(?::[^@\s]*)?@/g;
14
+ const execFileAsync = promisify(execFile);
15
+ export function defaultPaths(home = homedir()) {
16
+ const app = join(home, '.config', 'opencode', 'conversation-archiver');
17
+ return {
18
+ app,
19
+ config: join(app, 'config.json'),
20
+ state: join(app, 'state'),
21
+ index: join(app, 'state', '_index.json'),
22
+ log: join(app, 'archive.log'),
23
+ pushLog: join(app, 'push.log'),
24
+ credentials: join(app, 'git-credentials'),
25
+ };
26
+ }
27
+ export function expandHome(path, home = homedir()) {
28
+ return path === '~'
29
+ ? home
30
+ : path.startsWith('~/')
31
+ ? join(home, path.slice(2))
32
+ : path;
33
+ }
34
+ export function maskUrl(text) {
35
+ return text.replace(URL_CREDENTIAL, '$1***@');
36
+ }
37
+ export function slugify(value) {
38
+ const slug = value
39
+ .trim()
40
+ .replace(/\s+/gu, '-')
41
+ .replace(/[^\p{L}\p{N}_-]/gu, '')
42
+ .replace(/-{2,}/gu, '-')
43
+ .replace(/^[-_]+|[-_]+$/gu, '')
44
+ .slice(0, MAX_SLUG)
45
+ .replace(/[-_]+$/gu, '');
46
+ return slug || 'untitled';
47
+ }
48
+ function visibleText(parts) {
49
+ const text = parts
50
+ .filter((part) => part.type === 'text' && !part.synthetic && !part.ignored)
51
+ .map(part => part.text.trim())
52
+ .filter(Boolean)
53
+ .join('\n\n');
54
+ return text || undefined;
55
+ }
56
+ export function extractBlocks(messages) {
57
+ const blocks = [];
58
+ for (const message of messages) {
59
+ const text = visibleText(message.parts);
60
+ if (text)
61
+ blocks.push({ key: message.info.id, role: message.info.role, text });
62
+ for (const part of message.parts) {
63
+ if (part.type === 'compaction') {
64
+ blocks.push({
65
+ key: part.id,
66
+ role: 'compact',
67
+ text: '> Context compacted. Earlier turns above are preserved in this archive.',
68
+ });
69
+ }
70
+ }
71
+ }
72
+ return blocks;
73
+ }
74
+ function shortID(id) {
75
+ return id.replace(/^ses_/, '').split('-')[0]?.slice(0, 8) || 'session';
76
+ }
77
+ function formatDate(timestamp) {
78
+ const date = new Date(timestamp);
79
+ const parts = new Intl.DateTimeFormat('en-CA', {
80
+ year: 'numeric',
81
+ month: '2-digit',
82
+ day: '2-digit',
83
+ hour: '2-digit',
84
+ minute: '2-digit',
85
+ hour12: false,
86
+ }).formatToParts(date);
87
+ const get = (type) => parts.find(part => part.type === type)?.value || '00';
88
+ const day = `${get('year')}-${get('month')}-${get('day')}`;
89
+ return {
90
+ month: `${get('year')}-${get('month')}`,
91
+ file: `${day}-${get('hour')}${get('minute')}`,
92
+ header: date.toLocaleString(undefined, {
93
+ dateStyle: 'medium',
94
+ timeStyle: 'short',
95
+ }),
96
+ day,
97
+ };
98
+ }
99
+ export function renderMarkdown(state, sessionID) {
100
+ const users = state.blocks.filter(block => block.role === 'user').length;
101
+ const assistants = state.blocks.filter(block => block.role === 'assistant').length;
102
+ const lines = [
103
+ `# ${state.title || `Session ${shortID(sessionID)}`}`,
104
+ '',
105
+ `- **Session**: \`${sessionID}\``,
106
+ `- **Started**: ${formatDate(state.start).header}`,
107
+ ];
108
+ if (state.machine.hostname)
109
+ lines.push(`- **Machine**: ${state.machine.hostname}`);
110
+ if (state.machine.tmux)
111
+ lines.push(`- **tmux session**: \`${state.machine.tmux}\``);
112
+ lines.push(`- **Turns archived**: ${users} user / ${assistants} assistant`, '', '---', '');
113
+ for (const block of state.blocks) {
114
+ if (block.role === 'compact')
115
+ lines.push(block.text, '');
116
+ else
117
+ lines.push(block.role === 'user' ? '## User' : '## Assistant', '', block.text.trimEnd(), '');
118
+ }
119
+ return `${lines.join('\n').trimEnd()}\n`;
120
+ }
121
+ function body(markdown) {
122
+ return (markdown.split('\n---\n', 2)[1] || markdown).trim();
123
+ }
124
+ export function bodyCovers(oldBody, newBody) {
125
+ if (!oldBody || oldBody === newBody)
126
+ return true;
127
+ return (newBody.startsWith(oldBody) &&
128
+ newBody.slice(oldBody.length).startsWith('\n\n'));
129
+ }
130
+ async function readJSON(path, fallback) {
131
+ try {
132
+ const value = JSON.parse(await readFile(path, 'utf8'));
133
+ return value && typeof value === 'object' ? value : fallback;
134
+ }
135
+ catch {
136
+ return fallback;
137
+ }
138
+ }
139
+ async function atomicJSON(path, value, mode) {
140
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
141
+ const temporary = `${path}.${process.pid}.tmp`;
142
+ await writeFile(temporary, JSON.stringify(value), { mode: mode ?? 0o600 });
143
+ await chmod(temporary, mode ?? 0o600);
144
+ await rename(temporary, path);
145
+ }
146
+ export async function loadConfig(paths = defaultPaths()) {
147
+ return readJSON(paths.config, {});
148
+ }
149
+ export async function saveConfig(config, paths = defaultPaths()) {
150
+ await atomicJSON(paths.config, config);
151
+ }
152
+ export function effectiveRepo(config, home = homedir()) {
153
+ return resolve(expandHome(process.env.OPENCODE_ARCHIVE_REPO ||
154
+ config.repo ||
155
+ '~/opencode-conversations', home));
156
+ }
157
+ export function effectiveMode(config) {
158
+ const mode = process.env.OPENCODE_ARCHIVE_MODE || config.mode || 'auto';
159
+ return mode === 'manual' ? 'manual' : 'auto';
160
+ }
161
+ async function log(paths, message) {
162
+ try {
163
+ await mkdir(paths.app, { recursive: true, mode: 0o700 });
164
+ await appendFile(paths.log, `[${new Date().toISOString()}] ${maskUrl(message)}\n`);
165
+ }
166
+ catch {
167
+ // Archiving must never disrupt OpenCode.
168
+ }
169
+ }
170
+ export async function run(repo, command, args, timeout = 30_000) {
171
+ try {
172
+ const result = await execFileAsync(command, args, {
173
+ cwd: repo,
174
+ encoding: 'utf8',
175
+ timeout,
176
+ });
177
+ return { status: 0, stdout: result.stdout, stderr: result.stderr };
178
+ }
179
+ catch (error) {
180
+ const failure = error;
181
+ return {
182
+ status: failure.killed
183
+ ? 124
184
+ : typeof failure.code === 'number'
185
+ ? failure.code
186
+ : 127,
187
+ stdout: failure.stdout || '',
188
+ stderr: failure.stderr || failure.message,
189
+ };
190
+ }
191
+ }
192
+ export function git(repo, args, timeout = 30_000) {
193
+ return run(repo, 'git', ['-C', repo, ...args], timeout);
194
+ }
195
+ async function ensureRepo(repo) {
196
+ await mkdir(repo, { recursive: true });
197
+ if (!existsSync(join(repo, '.git')))
198
+ await git(repo, ['init']);
199
+ if (!(await git(repo, ['config', 'user.email'])).stdout.trim()) {
200
+ await git(repo, ['config', 'user.email', 'opencode-archiver@localhost']);
201
+ await git(repo, ['config', 'user.name', 'opencode-conversation-archiver']);
202
+ }
203
+ if (!existsSync(join(repo, '.gitignore')))
204
+ await writeFile(join(repo, '.gitignore'), '.DS_Store\n');
205
+ }
206
+ async function stageArchives(repo, config) {
207
+ const pathspec = config.subdir || ':(glob)**/*.md';
208
+ await git(repo, ['add', '-A', '--', pathspec, '.gitignore']);
209
+ }
210
+ async function stageMigrationSources(repo, archivePaths) {
211
+ if (archivePaths.length)
212
+ await git(repo, ['add', '-A', '--', ...archivePaths]);
213
+ }
214
+ async function tmuxSession() {
215
+ if (!process.env.TMUX)
216
+ return undefined;
217
+ const result = await run(process.cwd(), 'tmux', ['display-message', '-p', '#S'], 5_000);
218
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
219
+ }
220
+ async function withLock(path, callback, waitMs = 30_000, staleMs = 5 * 60_000) {
221
+ await mkdir(dirname(path), { recursive: true });
222
+ const deadline = Date.now() + waitMs;
223
+ while (true) {
224
+ try {
225
+ await mkdir(path);
226
+ break;
227
+ }
228
+ catch (error) {
229
+ if (error.code !== 'EEXIST' ||
230
+ Date.now() >= deadline)
231
+ throw error;
232
+ if (Date.now() - (await stat(path)).mtimeMs > staleMs) {
233
+ await rm(path, { recursive: true, force: true });
234
+ continue;
235
+ }
236
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
237
+ }
238
+ }
239
+ try {
240
+ return await callback();
241
+ }
242
+ finally {
243
+ await rm(path, { recursive: true, force: true });
244
+ }
245
+ }
246
+ async function resolvePath(repo, state, sessionID, config, paths, excluded) {
247
+ const index = await readJSON(paths.index, {});
248
+ const date = formatDate(state.start);
249
+ const prefix = config.subdir
250
+ ? `${config.subdir.replace(/^\/+|\/+$/g, '')}/`
251
+ : '';
252
+ const base = `${prefix}${date.month}/${date.file}-${slugify(state.title || shortID(sessionID))}`;
253
+ const choices = [
254
+ `${base}.md`,
255
+ `${base}-${shortID(sessionID)}.md`,
256
+ `${base}-${sessionID}.md`,
257
+ ];
258
+ if (state.file &&
259
+ state.diverted &&
260
+ state.file !== excluded &&
261
+ index[state.file] === sessionID &&
262
+ existsSync(join(repo, state.file)))
263
+ return state.file;
264
+ for (const choice of choices) {
265
+ if (choice === excluded)
266
+ continue;
267
+ const owner = index[choice];
268
+ if (owner && owner !== sessionID)
269
+ continue;
270
+ if (choice === state.file ||
271
+ owner === sessionID ||
272
+ (!owner && !existsSync(join(repo, choice))))
273
+ return choice;
274
+ }
275
+ // All deterministic names may already protect richer files; find an unused
276
+ // suffix because preservation takes priority over a stable filename.
277
+ for (let suffix = 1;; suffix += 1) {
278
+ const fallback = `${base}-${sessionID}-preserved-${suffix}.md`;
279
+ if (fallback !== excluded &&
280
+ !index[fallback] &&
281
+ !existsSync(join(repo, fallback)))
282
+ return fallback;
283
+ }
284
+ }
285
+ async function writeArchive(conversation, config, paths) {
286
+ const sessionID = conversation.session.id;
287
+ const statePath = join(paths.state, `${sessionID}.json`);
288
+ const extracted = extractBlocks(conversation.messages);
289
+ const fallback = {
290
+ title: conversation.session.title || '',
291
+ file: null,
292
+ start: conversation.session.time.created,
293
+ blocks: [],
294
+ machine: {},
295
+ diverted: false,
296
+ };
297
+ const loaded = await readJSON(statePath, fallback);
298
+ let state = {
299
+ title: typeof loaded.title === 'string' ? loaded.title : fallback.title,
300
+ file: typeof loaded.file === 'string' ? loaded.file : null,
301
+ start: typeof loaded.start === 'number' ? loaded.start : fallback.start,
302
+ blocks: Array.isArray(loaded.blocks) ? loaded.blocks : [],
303
+ machine: loaded.machine && typeof loaded.machine === 'object'
304
+ ? loaded.machine
305
+ : {},
306
+ diverted: loaded.diverted === true,
307
+ };
308
+ const previousRenderedBody = state.blocks.length
309
+ ? body(renderMarkdown(state, sessionID))
310
+ : '';
311
+ const positions = new Map(state.blocks.map((block, index) => [block.key, index]));
312
+ for (const block of extracted) {
313
+ const position = positions.get(block.key);
314
+ if (position === undefined) {
315
+ positions.set(block.key, state.blocks.length);
316
+ state.blocks.push(block);
317
+ continue;
318
+ }
319
+ state.blocks[position] = block;
320
+ }
321
+ if (!state.blocks.length)
322
+ return { repo: effectiveRepo(config), file: '', turns: 0, changed: false };
323
+ const incomingTitle = conversation.session.title;
324
+ const isPlaceholder = (title) => /^New session(?:\s*-|$)/i.test(title);
325
+ if (incomingTitle &&
326
+ (!state.title ||
327
+ (isPlaceholder(state.title) && !isPlaceholder(incomingTitle))))
328
+ state.title = incomingTitle;
329
+ state.machine.hostname ||= hostname();
330
+ const tmux = await tmuxSession();
331
+ if (tmux)
332
+ state.machine.tmux = tmux;
333
+ else if (!process.env.TMUX)
334
+ delete state.machine.tmux;
335
+ const repo = effectiveRepo(config);
336
+ await ensureRepo(repo);
337
+ let next = await resolvePath(repo, state, sessionID, config, paths);
338
+ const previous = state.file;
339
+ const indexBeforeRename = await readJSON(paths.index, {});
340
+ if (previous &&
341
+ previous !== next &&
342
+ indexBeforeRename[previous] === sessionID &&
343
+ existsSync(join(repo, previous))) {
344
+ await mkdir(dirname(join(repo, next)), { recursive: true });
345
+ const moved = await git(repo, ['mv', '--', previous, next]);
346
+ if (moved.status !== 0)
347
+ await rename(join(repo, previous), join(repo, next)).catch(() => {
348
+ next = previous;
349
+ });
350
+ }
351
+ const markdown = renderMarkdown(state, sessionID);
352
+ const target = join(repo, next);
353
+ if (existsSync(target)) {
354
+ const old = await readFile(target, 'utf8').catch(() => '__unreadable__');
355
+ const oldBody = body(old);
356
+ const isExpectedPriorRender = oldBody === previousRenderedBody;
357
+ if (!isExpectedPriorRender && !bodyCovers(oldBody, body(markdown))) {
358
+ state.file = null;
359
+ next = await resolvePath(repo, state, sessionID, config, paths, next);
360
+ state.diverted = true;
361
+ }
362
+ }
363
+ const destination = join(repo, next);
364
+ await mkdir(dirname(destination), { recursive: true });
365
+ const before = await readFile(destination, 'utf8').catch(() => '');
366
+ await writeFile(destination, markdown);
367
+ state.file = next;
368
+ await atomicJSON(statePath, state);
369
+ const index = await readJSON(paths.index, {});
370
+ if (previous && previous !== next && index[previous] === sessionID)
371
+ delete index[previous];
372
+ index[next] = sessionID;
373
+ await atomicJSON(paths.index, index);
374
+ return {
375
+ repo,
376
+ file: next,
377
+ turns: state.blocks.filter(block => block.role === 'user').length,
378
+ changed: before !== markdown,
379
+ };
380
+ }
381
+ export async function push(repo, paths = defaultPaths()) {
382
+ return withLock(join(paths.app, 'archive.lock'), () => withLock(join(paths.app, 'push.lock'), async () => {
383
+ if ((await git(repo, ['remote'])).stdout.trim()) {
384
+ const remoteHeads = await git(repo, [
385
+ 'ls-remote',
386
+ '--heads',
387
+ 'origin',
388
+ ]);
389
+ if (remoteHeads.status !== 0)
390
+ return remoteHeads;
391
+ if (!remoteHeads.stdout.trim()) {
392
+ const firstPush = await git(repo, ['push', '-u', 'origin', 'HEAD'], 120_000);
393
+ return firstPush;
394
+ }
395
+ const upstream = await git(repo, [
396
+ 'rev-parse',
397
+ '--abbrev-ref',
398
+ '--symbolic-full-name',
399
+ '@{u}',
400
+ ]);
401
+ if (upstream.status !== 0) {
402
+ const remoteHead = await git(repo, [
403
+ 'ls-remote',
404
+ '--symref',
405
+ 'origin',
406
+ 'HEAD',
407
+ ]);
408
+ if (remoteHead.status !== 0)
409
+ return remoteHead;
410
+ const branch = remoteHead.stdout.match(/ref: refs\/heads\/([^\s]+)\s+HEAD/)?.[1];
411
+ if (!branch)
412
+ return {
413
+ status: 1,
414
+ stdout: '',
415
+ stderr: 'remote has branches but no default branch was found',
416
+ };
417
+ const tracking = await git(repo, [
418
+ 'branch',
419
+ '--set-upstream-to',
420
+ `origin/${branch}`,
421
+ ]);
422
+ if (tracking.status !== 0)
423
+ return tracking;
424
+ }
425
+ const pull = await git(repo, [
426
+ '-c',
427
+ 'rebase.autoStash=true',
428
+ 'pull',
429
+ '--rebase',
430
+ '-X',
431
+ 'theirs',
432
+ ], 120_000);
433
+ if (pull.status !== 0) {
434
+ if (existsSync(join(repo, '.git', 'rebase-merge')) ||
435
+ existsSync(join(repo, '.git', 'rebase-apply')))
436
+ await git(repo, ['rebase', '--abort']);
437
+ await log(paths, `pull --rebase failed: ${pull.stderr || pull.stdout}`);
438
+ return pull;
439
+ }
440
+ }
441
+ const result = await git(repo, ['push'], 120_000);
442
+ await appendFile(paths.pushLog, maskUrl(`${result.stdout}${result.stderr}`)).catch(() => undefined);
443
+ return result;
444
+ }, PUSH_LOCK_WAIT_MS, PUSH_LOCK_STALE_MS), ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
445
+ }
446
+ function pushDetached(repo, paths) {
447
+ const child = spawn(process.execPath, [
448
+ fileURLToPath(new URL('./push-worker.js', import.meta.url)),
449
+ repo,
450
+ paths.app,
451
+ ], {
452
+ detached: true,
453
+ stdio: 'ignore',
454
+ });
455
+ child.unref();
456
+ }
457
+ export async function archiveConversation(conversation, paths = defaultPaths(), commit = true) {
458
+ return withLock(join(paths.app, 'archive.lock'), async () => {
459
+ const config = await loadConfig(paths);
460
+ const result = await writeArchive(conversation, config, paths);
461
+ if (!result.file || !commit || effectiveMode(config) === 'manual')
462
+ return { file: result.file, turns: result.turns };
463
+ await stageArchives(result.repo, config);
464
+ if (!(await git(result.repo, ['status', '--porcelain'])).stdout.trim())
465
+ return { file: result.file, turns: result.turns };
466
+ const title = conversation.session.title || shortID(conversation.session.id);
467
+ const committed = await git(result.repo, [
468
+ 'commit',
469
+ '-m',
470
+ `archive: ${formatDate(conversation.session.time.created).day} ${title}`,
471
+ ]);
472
+ if (committed.status === 0)
473
+ pushDetached(result.repo, paths);
474
+ else
475
+ await log(paths, `commit failed: ${committed.stderr}`);
476
+ return { file: result.file, turns: result.turns };
477
+ }, ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
478
+ }
479
+ export async function commitPending(paths = defaultPaths(), message = `manual upload: ${new Date().toLocaleString()}`) {
480
+ return withLock(join(paths.app, 'archive.lock'), () => commitPendingLocked(paths, message), ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
481
+ }
482
+ async function commitPendingLocked(paths, message) {
483
+ const repo = effectiveRepo(await loadConfig(paths));
484
+ if (!existsSync(join(repo, '.git')))
485
+ return { repo, committed: false };
486
+ await stageArchives(repo, await loadConfig(paths));
487
+ if (!(await git(repo, ['status', '--porcelain'])).stdout.trim())
488
+ return { repo, committed: false };
489
+ const result = await git(repo, ['commit', '-m', message]);
490
+ return result.status === 0
491
+ ? { repo, committed: true }
492
+ : {
493
+ repo,
494
+ committed: false,
495
+ error: maskUrl(result.stderr || result.stdout),
496
+ };
497
+ }
498
+ export async function upload(paths = defaultPaths()) {
499
+ const repo = effectiveRepo(await loadConfig(paths));
500
+ if (!existsSync(join(repo, '.git')))
501
+ return `archive repo not initialized yet (${repo})`;
502
+ const commit = await commitPending(paths);
503
+ if (commit.error)
504
+ return `commit failed: ${commit.error}`;
505
+ const result = await push(repo, paths);
506
+ return result.status === 0
507
+ ? `pushed (${repo})`
508
+ : `push skipped/failed: ${maskUrl(result.stderr || result.stdout)}`;
509
+ }
510
+ export async function setMode(mode, paths = defaultPaths()) {
511
+ return withLock(join(paths.app, 'archive.lock'), async () => {
512
+ const config = await loadConfig(paths);
513
+ config.mode = mode;
514
+ await saveConfig(config, paths);
515
+ return `conversation archiver mode: ${mode}`;
516
+ }, ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
517
+ }
518
+ export async function setRepo(path, paths = defaultPaths()) {
519
+ return withLock(join(paths.app, 'archive.lock'), async () => {
520
+ const expanded = resolve(expandHome(path));
521
+ if (!isAbsolute(expandHome(path)))
522
+ throw new Error('repo path must be absolute or start with ~/');
523
+ if (existsSync(expanded) && !(await stat(expanded)).isDirectory())
524
+ throw new Error(`not a directory: ${expanded}`);
525
+ const config = await loadConfig(paths);
526
+ const old = effectiveRepo(config);
527
+ config.repo = expanded;
528
+ await saveConfig(config, paths);
529
+ const warning = process.env.OPENCODE_ARCHIVE_REPO
530
+ ? ' OPENCODE_ARCHIVE_REPO currently overrides this setting.'
531
+ : '';
532
+ return old === expanded
533
+ ? `archive repo already set to ${expanded}.${warning}`
534
+ : `archive repo repointed to ${expanded}; previous archive left untouched at ${old}.${warning}`;
535
+ }, ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
536
+ }
537
+ export async function status(paths = defaultPaths()) {
538
+ const config = await loadConfig(paths);
539
+ const repo = effectiveRepo(config);
540
+ const lines = [`mode: ${effectiveMode(config)}`, `repo: ${repo}`];
541
+ if (!existsSync(join(repo, '.git')))
542
+ return [
543
+ ...lines,
544
+ 'remote: none',
545
+ '--- recent commits ---',
546
+ '(no commits yet)',
547
+ '--- pending changes ---',
548
+ ].join('\n');
549
+ const remotes = (await git(repo, ['remote', '-v'])).stdout
550
+ .trim()
551
+ .split('\n')
552
+ .filter(Boolean);
553
+ lines.push(`remote: ${maskUrl(remotes[0] || 'none')}`, '--- recent commits ---');
554
+ lines.push((await git(repo, ['log', '--oneline', '-5'])).stdout.trim() ||
555
+ '(no commits yet)', '--- pending changes ---');
556
+ lines.push(...(await git(repo, ['status', '--short'])).stdout
557
+ .trim()
558
+ .split('\n')
559
+ .slice(0, 10)
560
+ .filter(Boolean));
561
+ return lines.join('\n');
562
+ }
563
+ export async function doctor(paths = defaultPaths()) {
564
+ const config = await loadConfig(paths);
565
+ const repo = effectiveRepo(config);
566
+ const gitVersion = await run(process.cwd(), 'git', ['--version']);
567
+ const problems = [];
568
+ const lines = [
569
+ '# OpenCode conversation-archiver doctor',
570
+ '',
571
+ '## Dependencies',
572
+ ];
573
+ if (gitVersion.status === 0)
574
+ lines.push(`- git: OK (${gitVersion.stdout.trim()})`);
575
+ else {
576
+ lines.push('- git: MISSING');
577
+ problems.push('Install git and ensure it is on PATH.');
578
+ }
579
+ lines.push('', '## Config', `- mode: ${effectiveMode(config)}`, `- repo: ${repo}`);
580
+ if (process.env.OPENCODE_ARCHIVE_MODE || process.env.OPENCODE_ARCHIVE_REPO)
581
+ lines.push('- OPENCODE_ARCHIVE_* environment override(s) active');
582
+ lines.push('', '## Archive repo');
583
+ if (!existsSync(join(repo, '.git')))
584
+ lines.push('- not initialized yet; created after the first visible turn');
585
+ else {
586
+ lines.push('- git repo: OK', `- last commit: ${(await git(repo, ['log', '-1', '--oneline'])).stdout.trim() || '(none)'}`);
587
+ const names = (await git(repo, ['remote'])).stdout
588
+ .trim()
589
+ .split('\n')
590
+ .filter(Boolean);
591
+ if (!names.length)
592
+ lines.push('- remote: NONE; archive is local-only');
593
+ else {
594
+ const remote = names.includes('origin') ? 'origin' : names[0];
595
+ const probe = await git(repo, ['ls-remote', '--heads', remote], 20_000);
596
+ lines.push(`- remote: ${remote}`, `- connection test: ${probe.status === 0 ? 'OK' : 'FAILED'}`);
597
+ if (probe.status !== 0)
598
+ problems.push(`Remote probe failed: ${maskUrl(probe.stderr || probe.stdout)}`);
599
+ }
600
+ }
601
+ const files = existsSync(paths.state) ? await readdir(paths.state) : [];
602
+ lines.push('', '## Archive activity', `- sessions tracked: ${files.filter(file => file.endsWith('.json') && file !== '_index.json').length}`);
603
+ lines.push('', '## Verdict', problems.length
604
+ ? `Found ${problems.length} problem(s):\n${problems.map((item, index) => ` ${index + 1}. ${item}`).join('\n')}`
605
+ : 'All checks passed.');
606
+ return lines.join('\n');
607
+ }
608
+ export async function connect(remoteUrl, token, subdir = 'opencode', paths = defaultPaths()) {
609
+ // Connect is intentionally one archive transaction: its HTTP calls are capped
610
+ // at 30s and git phases at 120s, while archive wait/stale limits are 15m/20m.
611
+ // Keeping the lock prevents a turn from committing during migration/rebase.
612
+ return withLock(join(paths.app, 'archive.lock'), () => connectLocked(remoteUrl, token, subdir, paths), ARCHIVE_LOCK_WAIT_MS, ARCHIVE_LOCK_STALE_MS);
613
+ }
614
+ async function connectLocked(remoteUrl, token, subdir, paths) {
615
+ const config = await loadConfig(paths);
616
+ const repo = effectiveRepo(config);
617
+ await ensureRepo(repo);
618
+ let url = remoteUrl.trim();
619
+ let credential = token.trim();
620
+ if (url.includes('/sb-connect/')) {
621
+ const parsed = new URL(url);
622
+ const code = parsed.pathname.split('/').filter(Boolean).at(-1);
623
+ const response = await fetch(`${parsed.origin}/api/memo_v2/sources/claude-code/activate`, {
624
+ method: 'POST',
625
+ headers: { 'Content-Type': 'application/json' },
626
+ body: JSON.stringify({ code }),
627
+ signal: AbortSignal.timeout(30_000),
628
+ });
629
+ if (!response.ok)
630
+ throw new Error(response.status === 404
631
+ ? 'connect link expired or already used'
632
+ : `connect failed: HTTP ${response.status}`);
633
+ const data = (await response.json());
634
+ url = data.remote_url || '';
635
+ credential = data.token || '';
636
+ }
637
+ if (!credential) {
638
+ credential = process.env.GSK_API_KEY || '';
639
+ if (!credential) {
640
+ const gsk = await readJSON(join(homedir(), '.genspark-tool-cli', 'config.json'), {});
641
+ credential = gsk.api_key || '';
642
+ }
643
+ }
644
+ if (!url && credential) {
645
+ const base = (process.env.GSK_BASE_URL || 'https://www.genspark.ai').replace(/\/$/, '');
646
+ const response = await fetch(`${base}/api/memo_v2/sources/claude-code/resolve`, {
647
+ headers: { Authorization: `Bearer ${credential}` },
648
+ signal: AbortSignal.timeout(30_000),
649
+ });
650
+ if (!response.ok)
651
+ throw new Error(`could not resolve Second Brain remote: HTTP ${response.status}`);
652
+ url = (await response.json()).remote_url || '';
653
+ }
654
+ if (!url || !credential)
655
+ throw new Error('connect requires an sb-connect link, a signed-in gsk CLI, or remote URL plus token');
656
+ const parsed = new URL(url);
657
+ if (!['http:', 'https:'].includes(parsed.protocol))
658
+ throw new Error('remote URL must use HTTP(S)');
659
+ const normalizedSubdir = subdir.replace(/^\/+|\/+$/g, '') || 'opencode';
660
+ if (normalizedSubdir.includes('\\') ||
661
+ normalizedSubdir
662
+ .split('/')
663
+ .some(part => !part || part === '.' || part === '..'))
664
+ throw new Error('subdir must be a relative path without empty, . or .. components');
665
+ const migratedSources = await migrateArchiveSubdir(repo, normalizedSubdir, paths);
666
+ await mkdir(paths.app, { recursive: true, mode: 0o700 });
667
+ await rm(paths.credentials, { force: true });
668
+ await writeFile(paths.credentials, `${parsed.protocol}//x-access-token:${credential}@${parsed.host}\n`, { mode: 0o600 });
669
+ await git(repo, [
670
+ 'config',
671
+ 'credential.helper',
672
+ `store --file=${JSON.stringify(paths.credentials)}`,
673
+ ]);
674
+ await git(repo, ['config', 'push.default', 'upstream']);
675
+ const remotes = (await git(repo, ['remote'])).stdout.split('\n');
676
+ await git(repo, remotes.includes('origin')
677
+ ? ['remote', 'set-url', 'origin', url]
678
+ : ['remote', 'add', 'origin', url]);
679
+ config.mode = 'auto';
680
+ config.subdir = normalizedSubdir;
681
+ await saveConfig(config, paths);
682
+ await stageMigrationSources(repo, migratedSources);
683
+ const migrationCommit = await commitPendingLocked(paths, `connect: move archive under ${normalizedSubdir}/`);
684
+ if (migrationCommit.error)
685
+ return `remote and credential saved, but archive commit failed: ${migrationCommit.error}`;
686
+ const sparse = await git(repo, ['sparse-checkout', 'set', config.subdir]);
687
+ if (sparse.status !== 0)
688
+ return `remote and credential saved, archive migration committed, but sparse checkout failed: ${maskUrl(sparse.stderr || sparse.stdout)}`;
689
+ return withLock(join(paths.app, 'push.lock'), async () => {
690
+ const remoteHead = await git(repo, [
691
+ 'ls-remote',
692
+ '--symref',
693
+ 'origin',
694
+ 'HEAD',
695
+ ]);
696
+ if (remoteHead.status !== 0)
697
+ return `remote and credential saved, but remote probe failed: ${maskUrl(remoteHead.stderr || remoteHead.stdout)}`;
698
+ const remoteHeads = await git(repo, ['ls-remote', '--heads', 'origin']);
699
+ if (remoteHeads.status !== 0)
700
+ return `remote and credential saved, but remote probe failed: ${maskUrl(remoteHeads.stderr || remoteHeads.stdout)}`;
701
+ const branch = remoteHead.stdout.match(/ref: refs\/heads\/([^\s]+)\s+HEAD/)?.[1] ||
702
+ 'main';
703
+ const fetched = await git(repo, ['fetch', 'origin', branch], 120_000);
704
+ if (fetched.status !== 0 && remoteHeads.stdout.trim())
705
+ return `remote and credential saved, but fetch failed: ${maskUrl(fetched.stderr)}`;
706
+ if (!remoteHeads.stdout.trim()) {
707
+ const firstPush = await git(repo, ['push', '-u', 'origin', `HEAD:${branch}`], 120_000);
708
+ return firstPush.status === 0
709
+ ? `connected; archive syncs under ${config.subdir}/`
710
+ : `remote and credential saved; first push failed and will retry: ${maskUrl(firstPush.stderr)}`;
711
+ }
712
+ const head = await git(repo, ['rev-parse', '--verify', 'HEAD']);
713
+ if (head.status === 0) {
714
+ const pull = await git(repo, ['pull', '--rebase', '-X', 'theirs', 'origin', branch], 120_000);
715
+ if (pull.status !== 0) {
716
+ if (existsSync(join(repo, '.git', 'rebase-merge')) ||
717
+ existsSync(join(repo, '.git', 'rebase-apply')))
718
+ await git(repo, ['rebase', '--abort']);
719
+ return `remote and credential saved, but pull failed: ${maskUrl(pull.stderr || pull.stdout)}`;
720
+ }
721
+ }
722
+ else {
723
+ const reset = await git(repo, ['reset', '--hard', 'FETCH_HEAD']);
724
+ if (reset.status !== 0)
725
+ return `remote and credential saved, but checkout failed: ${maskUrl(reset.stderr || reset.stdout)}`;
726
+ }
727
+ const pushed = await git(repo, ['push', '-u', 'origin', `HEAD:${branch}`], 120_000);
728
+ return pushed.status === 0
729
+ ? `connected; archive syncs under ${config.subdir}/`
730
+ : `remote and credential saved; first push failed and will retry: ${maskUrl(pushed.stderr)}`;
731
+ }, PUSH_LOCK_WAIT_MS, PUSH_LOCK_STALE_MS);
732
+ }
733
+ async function migrateArchiveSubdir(repo, subdir, paths) {
734
+ const index = await readJSON(paths.index, {});
735
+ const removedSources = [];
736
+ for (const [archivePath, sessionID] of Object.entries(index)) {
737
+ if (archivePath.startsWith(`${subdir}/`))
738
+ continue;
739
+ const destination = `${subdir}/${archivePath}`;
740
+ if (!existsSync(join(repo, archivePath)))
741
+ continue;
742
+ const destinationOwner = index[destination];
743
+ if (destinationOwner && destinationOwner !== sessionID) {
744
+ await rm(join(repo, archivePath), { force: true });
745
+ removedSources.push(archivePath);
746
+ delete index[archivePath];
747
+ const statePath = join(paths.state, `${sessionID}.json`);
748
+ const state = await readJSON(statePath, {});
749
+ if (state.file === archivePath) {
750
+ state.file = null;
751
+ await atomicJSON(statePath, state);
752
+ }
753
+ await atomicJSON(paths.index, index);
754
+ continue;
755
+ }
756
+ if (existsSync(join(repo, destination))) {
757
+ const destinationText = await readFile(join(repo, destination), 'utf8').catch(() => '');
758
+ const sourceText = await readFile(join(repo, archivePath), 'utf8').catch(() => '');
759
+ if (!destinationText || destinationText !== sourceText) {
760
+ await rm(join(repo, archivePath), { force: true });
761
+ removedSources.push(archivePath);
762
+ delete index[archivePath];
763
+ const statePath = join(paths.state, `${sessionID}.json`);
764
+ const state = await readJSON(statePath, {});
765
+ if (state.file === archivePath) {
766
+ state.file = null;
767
+ await atomicJSON(statePath, state);
768
+ }
769
+ await atomicJSON(paths.index, index);
770
+ continue;
771
+ }
772
+ await rm(join(repo, archivePath), { force: true });
773
+ removedSources.push(archivePath);
774
+ }
775
+ else {
776
+ await mkdir(dirname(join(repo, destination)), { recursive: true });
777
+ const moved = await git(repo, ['mv', '--', archivePath, destination]);
778
+ if (moved.status !== 0) {
779
+ try {
780
+ await rename(join(repo, archivePath), join(repo, destination));
781
+ }
782
+ catch {
783
+ await rm(join(repo, archivePath), { force: true });
784
+ removedSources.push(archivePath);
785
+ delete index[archivePath];
786
+ const statePath = join(paths.state, `${sessionID}.json`);
787
+ const state = await readJSON(statePath, {});
788
+ if (state.file === archivePath) {
789
+ state.file = null;
790
+ await atomicJSON(statePath, state);
791
+ }
792
+ await atomicJSON(paths.index, index);
793
+ continue;
794
+ }
795
+ }
796
+ }
797
+ removedSources.push(archivePath);
798
+ delete index[archivePath];
799
+ index[destination] = sessionID;
800
+ const statePath = join(paths.state, `${sessionID}.json`);
801
+ const state = await readJSON(statePath, {});
802
+ if (state.file === archivePath) {
803
+ state.file = destination;
804
+ await atomicJSON(statePath, state);
805
+ }
806
+ await atomicJSON(paths.index, index);
807
+ }
808
+ return removedSources;
809
+ }
810
+ export async function emitNotification(sessionID, title, turns) {
811
+ if (process.env.OPENCODE_ARCHIVE_NO_NOTIFY)
812
+ return;
813
+ const payload = Buffer.from(JSON.stringify({
814
+ v: 1,
815
+ magic: 'genterm-notify',
816
+ source: 'opencode-conversation-archiver',
817
+ sourceId: sessionID,
818
+ event: 'session.idle',
819
+ title: title || 'OpenCode',
820
+ body: `Turn complete · ${turns} ${turns === 1 ? 'turn' : 'turns'} archived`,
821
+ })).toString('base64');
822
+ let sequence = `\u001b]9999;${payload}\u001b\\`;
823
+ const tty = process.env.TMUX
824
+ ? (await run(process.cwd(), 'tmux', ['display-message', '-p', '#{pane_tty}'], 5_000)).stdout.trim()
825
+ : '/dev/tty';
826
+ if (!tty)
827
+ return;
828
+ if (process.env.TMUX) {
829
+ await run(process.cwd(), 'tmux', ['set', '-p', 'allow-passthrough', 'on'], 5_000);
830
+ sequence = `\u001bPtmux;${sequence.replaceAll('\u001b', '\u001b\u001b')}\u001b\\`;
831
+ }
832
+ await writeFile(tty, sequence).catch(() => undefined);
833
+ }