pi-optchat 0.5.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.
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { Memory, bytes, type Entry, type Compressor } from '../memory.ts';
5
+ import { atomicWrite } from '../profiles.ts';
6
+ import { record } from '../cache.ts';
7
+ import type { ImportedEntry } from './sources.ts';
8
+
9
+ export type ImportMode = 'append' | 'rebuild';
10
+ export interface ImportJob {
11
+ id: string; mode: ImportMode; previous: string; target: string; created: string;
12
+ added: number; skipped: number; total: number; inputBytes: number;
13
+ }
14
+ const pendingFile = (dir: string) => join(dir, 'imports', 'pending.json');
15
+ function generationPath(dir: string, name: string) {
16
+ if (name !== '.' && !/^memories\/[a-f0-9-]{36}$/.test(name)) throw new Error('Invalid memory generation path.');
17
+ return join(dir, name);
18
+ }
19
+ export function memoryDirectory(dir: string): string {
20
+ const file = join(dir, 'active-memory.json');
21
+ if (!existsSync(file)) return dir;
22
+ const name: unknown = JSON.parse(readFileSync(file, 'utf8'));
23
+ if (typeof name !== 'string') throw new Error('Invalid active memory pointer.');
24
+ const path = generationPath(dir, name);
25
+ if (!existsSync(join(path, 'main'))) throw new Error('Active memory generation is missing.');
26
+ return path;
27
+ }
28
+ export function pendingImport(dir: string): ImportJob | undefined {
29
+ if (!existsSync(pendingFile(dir))) return undefined;
30
+ const j: unknown = JSON.parse(readFileSync(pendingFile(dir), 'utf8'));
31
+ if (!record(j) || typeof j.id !== 'string' || !/^[a-f0-9-]{36}$/.test(j.id)
32
+ || !['append', 'rebuild'].includes(String(j.mode)) || typeof j.previous !== 'string' || j.target !== `memories/${j.id}`
33
+ || typeof j.created !== 'string' || !['added', 'skipped', 'total', 'inputBytes'].every(k => Number.isSafeInteger(j[k]) && Number(j[k]) >= 0))
34
+ throw new Error('Invalid import journal. Refusing to change profile memory.');
35
+ generationPath(dir, j.previous);
36
+ return { id: j.id, mode: j.mode === 'append' ? 'append' : 'rebuild', previous: j.previous, target: `memories/${j.id}`,
37
+ created: j.created, added: Number(j.added), skipped: Number(j.skipped), total: Number(j.total), inputBytes: Number(j.inputBytes) };
38
+ }
39
+ export function deduplicate(existing: readonly Entry[], incoming: readonly ImportedEntry[]) {
40
+ const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
41
+ const added: ImportedEntry[] = []; let skipped = 0;
42
+ for (const entry of incoming) {
43
+ if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
44
+ if (receipts.has(entry.receipt)) { skipped++; continue; }
45
+ receipts.add(entry.receipt); added.push(entry);
46
+ }
47
+ return { added, skipped };
48
+ }
49
+ /** Imported conversations remain contiguous; existing native history is grouped by user turn. */
50
+ export function chronological(entries: readonly ImportedEntry[]) {
51
+ const groups = new Map<string, { date: string; entries: ImportedEntry[] }>();
52
+ let native = 0;
53
+ for (const entry of entries) {
54
+ if (!entry.origin && entry.kind === 'user') native++;
55
+ const id = entry.origin ? JSON.stringify([entry.origin.source, entry.origin.conversation]) : `native:${native}`;
56
+ const group = groups.get(id) ?? { date: entry.date, entries: [] };
57
+ group.date = group.date < entry.date ? group.date : entry.date;
58
+ group.entries.push(entry); groups.set(id, group);
59
+ }
60
+ return [...groups.values()].sort((a, b) => a.date.localeCompare(b.date)).flatMap(g => g.entries);
61
+ }
62
+ /** Call only while holding the profile lock, after closing its old Memory worker. */
63
+ export function prepareImport(dir: string, old: Memory, incoming: readonly ImportedEntry[], mode: ImportMode): ImportJob | undefined {
64
+ if (pendingImport(dir)) throw new Error('Resume or discard the pending import first.');
65
+ const { added, skipped } = deduplicate(old.root, incoming);
66
+ if (!added.length) return undefined;
67
+ const id = randomUUID(), target = `memories/${id}`, path = generationPath(dir, target);
68
+ // Append copies existing summaries without changing their indices. Rebuild regenerates every node.
69
+ const all = mode === 'rebuild' ? chronological([...old.root, ...added]) : [...old.root, ...chronological(added)];
70
+ const entries: Entry[] = all.map((e, i) => ({ ...e, i, size: bytes(`${e.kind}: ${e.text}`) }));
71
+ mkdirSync(join(path, 'tree'), { recursive: true, mode: 0o700 });
72
+ atomicWrite(join(path, 'main', '000-import.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
73
+ if (mode === 'append') cpSync(join(old.directory, 'tree'), join(path, 'tree'), { recursive: true });
74
+ const job: ImportJob = { id, mode, previous: relative(dir, old.directory) || '.', target,
75
+ created: new Date().toISOString(), added: added.length, skipped, total: entries.length,
76
+ inputBytes: (mode === 'rebuild' ? entries : added).reduce((n, e) => n + bytes(e.text), 0) };
77
+ atomicWrite(pendingFile(dir), JSON.stringify(job, null, 2));
78
+ return job;
79
+ }
80
+ export function discardImport(dir: string) {
81
+ const job = pendingImport(dir); if (!job) return;
82
+ if (memoryDirectory(dir) === generationPath(dir, job.target)) throw new Error('Import is already activated; it cannot be discarded.');
83
+ // Remove the journal first: any interruption leaves only an unused staging directory.
84
+ rmSync(pendingFile(dir)); rmSync(generationPath(dir, job.target), { recursive: true, force: true });
85
+ }
86
+ export interface ImportProgress { messages: number; total: number; summaries: number; error?: string }
87
+ export async function runImport(dir: string, compress: Compressor, signal: AbortSignal,
88
+ progress: (state: ImportProgress) => void = () => {}): Promise<ImportJob> {
89
+ const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
90
+ const path = generationPath(dir, job.target);
91
+ if (!existsSync(join(path, 'main', '000-import.jsonl'))) throw new Error('Import staging data is missing; original memory remains intact.');
92
+ const memory = new Memory(path, compress, () => {});
93
+ const report = () => progress({ messages: memory.root.length - memory.pending, total: memory.root.length, summaries: memory.tree.size, error: memory.lastError });
94
+ const timer = setInterval(report, 500);
95
+ try {
96
+ report(); await memory.settle(signal, true); signal.throwIfAborted();
97
+ // Close all writers before the single atomic pointer swap. The previous generation stays intact.
98
+ await memory.close();
99
+ atomicWrite(join(dir, 'imports', `${job.id}.json`), JSON.stringify({ ...job, completed: new Date().toISOString() }, null, 2));
100
+ atomicWrite(join(dir, 'active-memory.json'), JSON.stringify(job.target));
101
+ rmSync(pendingFile(dir));
102
+ report(); return job;
103
+ } finally { clearInterval(timer); await memory.close(); }
104
+ }
@@ -0,0 +1,98 @@
1
+ import { Input, matchesKey, truncateToWidth, type Component, type Focusable } from '@earendil-works/pi-tui';
2
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
3
+
4
+ const clean = (text: string) => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
5
+ interface Options {
6
+ title: string;
7
+ labels: string[];
8
+ rows: () => number;
9
+ redraw: () => void;
10
+ done: (indices: number[] | undefined) => void;
11
+ color: (tone: 'accent' | 'dim' | 'error', text: string) => string;
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ /** One mounted picker: toggling never recreates the dialog or resets its viewport. */
16
+ export class MultiSelect implements Component, Focusable {
17
+ private readonly filter = new Input({ prompt: 'Filter: ', placeholder: 'type to filter' });
18
+ private readonly labels: string[];
19
+ private readonly selected = new Set<number>();
20
+ private visible: number[];
21
+ private cursor = 0;
22
+ private top = 0;
23
+ private ended = false;
24
+ private error = '';
25
+ constructor(private readonly options: Options) {
26
+ this.labels = options.labels.map(clean);
27
+ this.visible = this.labels.map((_, i) => i);
28
+ options.signal?.addEventListener('abort', this.abort, { once: true });
29
+ if (options.signal?.aborted) queueMicrotask(this.abort);
30
+ }
31
+ get focused() { return this.filter.focused; }
32
+ set focused(value: boolean) { this.filter.focused = value; }
33
+ private get pageSize() { return Math.max(1, Math.min(20, this.options.rows() - 12)); }
34
+ private readonly abort = () => this.finish(undefined);
35
+ private finish(result: number[] | undefined) {
36
+ if (this.ended) return;
37
+ this.dispose(); this.options.done(result);
38
+ }
39
+ dispose() { this.ended = true; this.options.signal?.removeEventListener('abort', this.abort); }
40
+ invalidate() { this.filter.invalidate(); }
41
+ handleInput(data: string) {
42
+ if (this.ended) return;
43
+ this.error = '';
44
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish(undefined);
45
+ if (matchesKey(data, 'return')) {
46
+ if (this.selected.size) return this.finish([...this.selected].sort((a, b) => a - b));
47
+ this.error = 'Select at least one item with Space.';
48
+ } else if (matchesKey(data, 'space')) {
49
+ const index = this.visible[this.cursor];
50
+ if (index !== undefined) { if (this.selected.has(index)) this.selected.delete(index); else this.selected.add(index); }
51
+ } else if (matchesKey(data, 'ctrl+a')) this.visible.forEach(i => this.selected.add(i));
52
+ else if (matchesKey(data, 'ctrl+d')) this.visible.forEach(i => this.selected.delete(i));
53
+ else if (matchesKey(data, 'down')) this.cursor++;
54
+ else if (matchesKey(data, 'up')) this.cursor--;
55
+ else if (matchesKey(data, 'pageDown')) this.cursor += this.pageSize;
56
+ else if (matchesKey(data, 'pageUp')) this.cursor -= this.pageSize;
57
+ else if (matchesKey(data, 'home')) this.cursor = 0;
58
+ else if (matchesKey(data, 'end')) this.cursor = this.visible.length - 1;
59
+ else {
60
+ const before = this.filter.getValue(); this.filter.handleInput(data);
61
+ if (this.filter.getValue() !== before) {
62
+ const query = clean(this.filter.getValue()).toLocaleLowerCase();
63
+ this.visible = this.labels.map((_, i) => i).filter(i => this.labels[i].toLocaleLowerCase().includes(query));
64
+ this.cursor = 0; this.top = 0;
65
+ }
66
+ }
67
+ this.cursor = Math.max(0, Math.min(this.cursor, this.visible.length - 1));
68
+ this.options.redraw();
69
+ }
70
+ render(width: number): string[] {
71
+ const { color } = this.options;
72
+ const page = this.pageSize;
73
+ this.top = Math.max(0, Math.min(this.top, this.visible.length - page));
74
+ if (this.cursor < this.top) this.top = this.cursor;
75
+ if (this.cursor >= this.top + page) this.top = this.cursor - page + 1;
76
+ const lines = [color('accent', `${clean(this.options.title)} · ${this.selected.size} selected`), ...this.filter.render(width), ''];
77
+ for (let row = this.top; row < Math.min(this.visible.length, this.top + page); row++) {
78
+ const index = this.visible[row];
79
+ const label = `${row === this.cursor ? '→' : ' '} [${this.selected.has(index) ? 'x' : ' '}] ${index + 1}. ${this.labels[index]}`;
80
+ lines.push(row === this.cursor ? color('accent', truncateToWidth(label, width)) : truncateToWidth(label, width));
81
+ }
82
+ if (!this.visible.length) lines.push('No matches. Ctrl+U clears the filter.');
83
+ lines.push(color('dim', `${this.visible.length ? this.cursor + 1 : 0}/${this.visible.length} matching · ${this.labels.length} total`));
84
+ lines.push(color(this.error ? 'error' : 'dim', this.error || '↑↓ move · PgUp/PgDn page · Home/End jump'));
85
+ lines.push(color('dim', 'Space toggle · Enter continue · Esc cancel'));
86
+ lines.push(color('dim', 'Ctrl+A select matches · Ctrl+D clear matches'));
87
+ return lines.map(line => truncateToWidth(line, width));
88
+ }
89
+ }
90
+
91
+ export async function selectMany<T>(ui: Pick<ExtensionUIContext, 'custom'>, title: string, items: T[], label: (item: T) => string, signal: AbortSignal): Promise<T[] | undefined> {
92
+ if (signal.aborted) return undefined;
93
+ const indices = await ui.custom<number[] | undefined>((tui, theme, _keys, done) => new MultiSelect({
94
+ title, labels: items.map(label), rows: () => tui.terminal.rows,
95
+ redraw: () => tui.requestRender(), done, color: (tone, text) => theme.fg(tone, text), signal,
96
+ }));
97
+ return indices?.map(i => items[i]);
98
+ }
@@ -0,0 +1,278 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { createHash } from 'node:crypto';
9
+ import { record } from '../cache.ts';
10
+ import { bytes, type Entry, type Kind, type Origin } from '../memory.ts';
11
+
12
+ export type Source = Origin['source'];
13
+ export type ImportedEntry = Omit<Entry, 'i' | 'size'>;
14
+ export interface Conversation {
15
+ source: Source; id: string; file: string; title: string; project: string; date: string; size: number;
16
+ exported?: Record<string, unknown>;
17
+ }
18
+ export interface Scan { conversations: Conversation[]; warnings: string[] }
19
+ const exec = promisify(execFile);
20
+ const string = (v: unknown) => typeof v === 'string' ? v : undefined;
21
+ const codexSubagent = (metadata: Record<string, unknown>) => metadata.source === 'subagent'
22
+ || record(metadata.source) && 'subagent' in metadata.source;
23
+ const missingSource = (error: unknown) => record(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
24
+ const missingWarning = (file: string) => `${file}: source file is no longer available; conversation skipped. Rescan to retry if it returns.`;
25
+ export function timestamp(value: unknown, fallback: string): string {
26
+ const time = typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN;
27
+ return Number.isFinite(time) ? new Date(time).toISOString() : fallback;
28
+ }
29
+ function text(value: unknown): string {
30
+ if (typeof value === 'string') return value;
31
+ if (Array.isArray(value)) return value.map(text).filter(Boolean).join('\n');
32
+ if (!record(value)) return '';
33
+ if (['thinking', 'redacted_thinking', 'reasoning', 'encrypted_content'].includes(String(value.type))) return '';
34
+ if (typeof value.text === 'string') return value.text;
35
+ if (['image', 'image_url', 'input_image', 'image_asset_pointer'].includes(String(value.type ?? value.content_type))) return '[image attachment; image bytes are not imported]';
36
+ if (value.content !== undefined) return text(value.content);
37
+ if (value.parts !== undefined) return text(value.parts);
38
+ if (typeof value.content_type === 'string') return `[${value.content_type} attachment; binary content is not imported]`;
39
+ return '';
40
+ }
41
+ const digest = (s: string) => createHash('sha256').update(s).digest('hex');
42
+ function imported(c: Conversation, id: string, kind: Kind, content: string, date: string, identity = content): ImportedEntry | undefined {
43
+ if (!content.trim()) return undefined;
44
+ // Text provenance survives compression. Stable per-message receipts survive moved files and repeated exports.
45
+ const origin: Origin = { source: c.source, conversation: c.id, message: id, title: c.title, project: c.project };
46
+ return { kind, date, origin, text: `[Historical ${c.source} · ${date} · conversation ${c.id} · ${c.title}]\n${content}`,
47
+ receipt: `import:${digest(JSON.stringify([c.source, c.id, id, kind, identity]))}` };
48
+ }
49
+ async function* jsonLines(file: string, warnings: string[], limit = Infinity, signal?: AbortSignal) {
50
+ const stream = createReadStream(file, { encoding: 'utf8', signal });
51
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
52
+ let n = 0;
53
+ try {
54
+ for await (const line of lines) {
55
+ n++; if (!line.trim()) continue;
56
+ try { const value: unknown = JSON.parse(line); if (record(value)) yield { value, line: n }; }
57
+ catch { warnings.push(`${file}:${n}: invalid JSON record skipped`); }
58
+ if (n >= limit) break;
59
+ }
60
+ } finally { lines.close(); stream.destroy(); }
61
+ }
62
+ async function filesUnder(path: string, accept: (name: string) => boolean, signal?: AbortSignal): Promise<string[]> {
63
+ signal?.throwIfAborted();
64
+ const entries = await readdir(path, { withFileTypes: true }).catch(error => {
65
+ if (record(error) && error.code === 'ENOENT') return [];
66
+ throw error;
67
+ });
68
+ const files: string[] = [];
69
+ for (const entry of entries) {
70
+ if (entry.isDirectory()) files.push(...await filesUnder(join(path, entry.name), accept, signal));
71
+ else if (entry.isFile() && accept(entry.name)) files.push(join(path, entry.name));
72
+ }
73
+ return files.sort();
74
+ }
75
+ export async function scanLocal(source: 'claude' | 'codex', roots?: string[], signal?: AbortSignal): Promise<Scan> {
76
+ const folders = roots ?? (source === 'claude' ? [join(homedir(), '.claude/projects')]
77
+ : [join(homedir(), '.codex/sessions'), join(homedir(), '.codex/archived_sessions')]);
78
+ const conversations: Conversation[] = [], warnings: string[] = [];
79
+ // Claude workflow journals contain orchestration events, not conversation messages.
80
+ for (const folder of folders) for (const file of await filesUnder(folder, n => n.endsWith('.jsonl') && !(source === 'claude' && n === 'journal.jsonl'), signal)) {
81
+ signal?.throwIfAborted();
82
+ // Import user conversations, not separate delegated runs (including Claude's older flat layout).
83
+ if (source === 'claude' && (relative(folder, dirname(file)).split(/[\\/]/).includes('subagents') || basename(file).startsWith('agent-'))) continue;
84
+ try {
85
+ const info = await stat(file);
86
+ let id = basename(file, '.jsonl'), project = dirname(file), date = info.mtime.toISOString(), title = '';
87
+ let sidechain = false;
88
+ for await (const { value: v, line } of jsonLines(file, warnings, source === 'claude' ? Infinity : 60, signal)) {
89
+ // Sidechain markers can appear late; picker metadata still comes from the first 60 lines.
90
+ if (source === 'claude' && v.isSidechain === true) { sidechain = true; break; }
91
+ if (line > 60) continue;
92
+ if (source === 'codex' && v.type === 'session_meta' && record(v.payload)) {
93
+ if (codexSubagent(v.payload)) { sidechain = true; break; }
94
+ id = string(v.payload.id) ?? id; project = string(v.payload.cwd) ?? project; date = timestamp(v.payload.timestamp ?? v.timestamp, date);
95
+ }
96
+ if (source === 'claude') {
97
+ id = string(v.sessionId) ?? id;
98
+ project = string(v.cwd) ?? project;
99
+ if (v.type === 'custom-title' || v.type === 'ai-title') title = string(v.customTitle ?? v.aiTitle) ?? title;
100
+ }
101
+ const m = source === 'claude' ? v.message : v.type === 'response_item' ? v.payload : undefined;
102
+ if (record(m) && m.role === 'user' && !title) {
103
+ title = text(m.content).replace(/\s+/g, ' ').slice(0, 110);
104
+ date = timestamp(v.timestamp, date);
105
+ }
106
+ }
107
+ if (sidechain) continue;
108
+ conversations.push({ source, file, id, project, date, title: title || id, size: info.size });
109
+ } catch (error) {
110
+ signal?.throwIfAborted();
111
+ if (!missingSource(error)) throw error;
112
+ warnings.push(missingWarning(file));
113
+ }
114
+ }
115
+ return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings };
116
+ }
117
+ export async function scanChatGPT(input: string, signal?: AbortSignal): Promise<Scan> {
118
+ const path = resolve(input.startsWith('~/') ? join(homedir(), input.slice(2)) : input);
119
+ const info = await stat(path);
120
+ const documents: { file: string; content: string }[] = [];
121
+ const accept = (name: string) => /^conversations(?:[-_]?\d+)?\.json$/i.test(basename(name));
122
+ if (info.isDirectory()) {
123
+ for (const file of await filesUnder(path, accept, signal)) documents.push({ file, content: await readFile(file, { encoding: 'utf8', signal }) });
124
+ } else if (path.toLowerCase().endsWith('.zip')) {
125
+ const listing = await exec('unzip', ['-Z1', path], { signal, maxBuffer: 10_000_000 });
126
+ for (const name of listing.stdout.split('\n').filter(accept)) {
127
+ const result = await exec('unzip', ['-p', path, name], { signal, maxBuffer: 1_000_000_000 });
128
+ documents.push({ file: `${path}:${name}`, content: result.stdout });
129
+ }
130
+ } else documents.push({ file: path, content: await readFile(path, { encoding: 'utf8', signal }) });
131
+ if (!documents.length) throw new Error('No conversations.json or numbered conversation JSON files found. Select a ChatGPT export ZIP, extracted folder, or JSON file.');
132
+ const conversations: Conversation[] = [], warnings: string[] = [];
133
+ for (const doc of documents) {
134
+ const data: unknown = JSON.parse(doc.content);
135
+ if (!Array.isArray(data)) throw new Error(`${doc.file}: expected an array of exported ChatGPT conversations.`);
136
+ for (const value of data) {
137
+ if (!record(value) || !record(value.mapping) || typeof (value.id ?? value.conversation_id) !== 'string') {
138
+ warnings.push(`${doc.file}: unrecognized conversation skipped`); continue;
139
+ }
140
+ const id = String(value.id ?? value.conversation_id);
141
+ conversations.push({ source: 'chatgpt', id, file: doc.file, title: string(value.title) ?? id,
142
+ project: 'ChatGPT', date: timestamp(value.create_time, info.mtime.toISOString()), size: bytes(JSON.stringify(value)), exported: value });
143
+ }
144
+ }
145
+ return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings };
146
+ }
147
+
148
+ export async function readConversation(c: Conversation, signal?: AbortSignal): Promise<{ entries: ImportedEntry[]; warnings: string[] }> {
149
+ signal?.throwIfAborted();
150
+ const entries: ImportedEntry[] = [], warnings: string[] = [];
151
+ const add = (id: string, kind: Kind, value: string, date: string, identity = value) => { const entry = imported(c, id, kind, value, date, identity); if (entry) entries.push(entry); };
152
+ if (c.source === 'chatgpt') {
153
+ const mapping = c.exported?.mapping;
154
+ if (!record(mapping)) throw new Error('ChatGPT conversation has no message mapping.');
155
+ // Include every branch once. Parent-first traversal handles missing child timestamps.
156
+ const nodes = Object.entries(mapping).filter((pair): pair is [string, Record<string, unknown>] => record(pair[1]));
157
+ nodes.sort((a, b) => timestamp(record(a[1].message) ? a[1].message.create_time : undefined, c.date)
158
+ .localeCompare(timestamp(record(b[1].message) ? b[1].message.create_time : undefined, c.date)));
159
+ const ordered: typeof nodes = [], visited = new Set<string>(), visiting = new Set<string>();
160
+ const visit = (key: string, node: Record<string, unknown>) => {
161
+ if (visited.has(key)) return;
162
+ if (visiting.has(key)) throw new Error(`${c.title}: cycle in ChatGPT conversation mapping.`);
163
+ visiting.add(key);
164
+ const parent = typeof node.parent === 'string' ? mapping[node.parent] : undefined;
165
+ if (typeof node.parent === 'string' && record(parent)) visit(node.parent, parent);
166
+ visiting.delete(key); visited.add(key); ordered.push([key, node]);
167
+ };
168
+ for (const [key, node] of nodes) visit(key, node);
169
+ const selected = new Set<string>();
170
+ let cursor = string(c.exported?.current_node);
171
+ const hasSelectedBranch = !!cursor;
172
+ while (cursor && !selected.has(cursor)) {
173
+ const node = mapping[cursor]; if (!record(node)) break;
174
+ selected.add(cursor); cursor = string(node.parent);
175
+ }
176
+ const dates = new Map<string, string>();
177
+ // For legacy exports without final/end_turn markers, exclude replies followed by
178
+ // more assistant/tool work before the next user message, on the same branch.
179
+ const intermediate = new Set<string>();
180
+ for (const [, node] of ordered) {
181
+ if (!record(node.message) || !record(node.message.author) || !['assistant', 'tool'].includes(String(node.message.author.role))) continue;
182
+ let parent = string(node.parent);
183
+ while (parent) {
184
+ const ancestor = mapping[parent]; if (!record(ancestor)) break;
185
+ if (record(ancestor.message) && record(ancestor.message.author) && ancestor.message.author.role === 'user') break;
186
+ if (intermediate.has(parent)) break;
187
+ intermediate.add(parent); parent = string(ancestor.parent);
188
+ }
189
+ }
190
+ for (const [key, node] of ordered) {
191
+ signal?.throwIfAborted();
192
+ const m = node.message;
193
+ const date = timestamp(record(m) ? m.create_time : undefined, dates.get(String(node.parent)) ?? c.date);
194
+ dates.set(key, date);
195
+ if (!record(m) || !record(m.author) || !['user', 'assistant'].includes(String(m.author.role))) continue;
196
+ if (m.channel === 'analysis' || m.channel === 'commentary'
197
+ || m.recipient && m.recipient !== 'all'
198
+ || record(m.content) && ['thoughts', 'reasoning', 'reasoning_recap'].includes(String(m.content.content_type))) continue;
199
+ const kind = m.author.role === 'user' ? 'user' : 'talk';
200
+ if (kind === 'talk' && (m.status && m.status !== 'finished_successfully'
201
+ || m.end_turn === false || m.channel !== 'final' && m.end_turn !== true && intermediate.has(key))) continue;
202
+ const content = text(m.content); if (!content) continue;
203
+ const branch = hasSelectedBranch ? selected.has(key) ? 'selected branch at export' : 'alternate branch, not the selected outcome' : 'branch selection unavailable';
204
+ add(string(m.id) ?? key, kind, `[message ${key}; parent ${String(node.parent ?? 'root')}; ${branch}${m.recipient ? `; recipient ${String(m.recipient)}` : ''}]\n${content}`, date, content);
205
+ }
206
+ if (hasSelectedBranch) {
207
+ const snapshot = timestamp(c.exported?.update_time, c.date);
208
+ add('export:selected-branch', 'note', `Selected ChatGPT branch in the ${snapshot} export snapshot ends at message ${String(c.exported?.current_node)}. Other branches are alternatives.`, snapshot);
209
+ }
210
+ } else {
211
+ let pending: ImportedEntry[] = [];
212
+ const finish = () => { entries.push(...pending); pending = []; };
213
+ const assistant = (parts: { id: string; content: string }[], date: string, final: boolean) => {
214
+ pending = parts.flatMap(part => {
215
+ const entry = imported(c, part.id, 'talk', part.content, date);
216
+ return entry ? [entry] : [];
217
+ });
218
+ if (final) finish();
219
+ };
220
+ try {
221
+ for await (const { value: v, line } of jsonLines(c.file, warnings, Infinity, signal)) {
222
+ const date = timestamp(v.timestamp, c.date);
223
+ // Context replay and compaction scaffolding are not new user requests.
224
+ if (c.source === 'claude' && v.type === 'system' && v.subtype === 'compact_boundary') { pending = []; continue; }
225
+ if (c.source === 'claude' && (v.isMeta === true || v.isCompactSummary === true)) continue;
226
+ if (c.source === 'claude' && v.isSidechain === true) return { entries: [], warnings };
227
+ if (c.source === 'claude' && ['user', 'assistant'].includes(String(v.type)) && record(v.message)) {
228
+ const m = v.message, id = string(v.uuid) ?? `line:${line}`;
229
+ if (m.role === 'user' && typeof m.content === 'string' && /^\[Request interrupted by user(?: for tool use)?\]$/.test(m.content)) { pending = []; continue; }
230
+ const blocks = Array.isArray(m.content) ? m.content : [];
231
+ const toolActivity = blocks.some(b => record(b) && (['tool_use', 'server_tool_use', 'tool_result'].includes(String(b.type)) || String(b.type).endsWith('_tool_result')));
232
+ if (toolActivity) pending = [];
233
+ const parts = typeof m.content === 'string' ? [{ id, content: m.content }] : blocks.flatMap((b, index) => {
234
+ if (!record(b)) return [];
235
+ if (['text', 'image', 'image_url', 'document'].includes(String(b.type))) {
236
+ const content = b.type === 'document' ? '[document attachment; binary content is not imported]' : text(b);
237
+ return content ? [{ id: `${id}:${index}`, content }] : [];
238
+ }
239
+ if (!['tool_use', 'server_tool_use', 'tool_result', 'thinking', 'redacted_thinking', 'reasoning', 'encrypted_content'].includes(String(b.type)) && !String(b.type).endsWith('_tool_result'))
240
+ warnings.push(`${c.file}:${line}: unsupported Claude content block ${String(b.type)} skipped`);
241
+ return [];
242
+ });
243
+ if (m.role === 'user' && parts.length) { finish(); for (const part of parts) add(part.id, 'user', part.content, date); }
244
+ else if (m.role === 'assistant') {
245
+ const final = m.stop_reason === 'end_turn' || m.stop_reason === 'stop_sequence';
246
+ if (toolActivity || v.isApiErrorMessage === true || m.stop_reason && !final) pending = [];
247
+ else if (parts.length) assistant(parts, date, final);
248
+ else if (!final) pending = [];
249
+ }
250
+ }
251
+ if (c.source === 'codex' && v.type === 'session_meta' && record(v.payload) && codexSubagent(v.payload)) return { entries: [], warnings };
252
+ if (c.source === 'codex' && v.type === 'event_msg' && record(v.payload)) {
253
+ if (v.payload.type === 'task_complete') finish();
254
+ if (['turn_aborted', 'task_started', 'task_failed'].includes(String(v.payload.type))) pending = [];
255
+ }
256
+ if (c.source === 'codex' && v.type === 'response_item' && record(v.payload)) {
257
+ const m = v.payload, id = string(m.id) ?? string(m.call_id) ?? `line:${line}`;
258
+ if (m.type === 'message' && m.role === 'user') { finish(); add(id, 'user', text(m.content), date); }
259
+ else if (m.type === 'message' && m.role === 'assistant') {
260
+ pending = [];
261
+ const channel = m.channel ?? m.phase;
262
+ const final = channel === 'final' || channel === 'final_answer';
263
+ if (!channel || final) assistant([{ id, content: text(m.content) }], date, final);
264
+ } else if (['function_call', 'custom_tool_call', 'function_call_output', 'custom_tool_call_output', 'web_search_call', 'image_generation_call', 'local_shell_call', 'agent_message'].includes(String(m.type))) pending = [];
265
+ else if (!['message', 'reasoning'].includes(String(m.type))) { pending = []; warnings.push(`${c.file}:${line}: unsupported response item ${String(m.type)} skipped`); }
266
+ }
267
+ }
268
+ finish(); // Older exports may omit explicit final markers; retain only the last text reply.
269
+ } catch (error) {
270
+ signal?.throwIfAborted();
271
+ if (!missingSource(error)) throw error;
272
+ // Do not stage a partial conversation if the source becomes unavailable mid-read.
273
+ return { entries: [], warnings: [...warnings, missingWarning(c.file)] };
274
+ }
275
+ }
276
+ const unique = new Map(entries.map(e => [e.receipt, e]));
277
+ return { entries: [...unique.values()], warnings };
278
+ }
@@ -0,0 +1,105 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
+ import type { Memory } from '../memory.ts';
3
+ import { bytes } from '../memory.ts';
4
+ import { scanLocal, scanChatGPT, readConversation, type Conversation, type ImportedEntry, type Source } from './sources.ts';
5
+ import { deduplicate, type ImportMode, type ImportJob, type ImportProgress } from './job.ts';
6
+ import { selectMany } from './multi-select.ts';
7
+ import { homedir } from 'node:os';
8
+
9
+ type ImportUI = { ui: Pick<ExtensionUIContext, 'select' | 'input' | 'confirm' | 'notify' | 'setWidget' | 'custom'> };
10
+ const clean = (s: string) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
11
+ const size = (n: number) => `${(n / 1_000_000).toFixed(1)} MB`;
12
+ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memory, model: string, signal: AbortSignal): Promise<{ entries: ImportedEntry[]; mode: ImportMode } | undefined> {
13
+ const sourceLabel = await ctx.ui.select(`Import into ${profile} · source`, ['Claude Code', 'Codex', 'ChatGPT export'], { signal });
14
+ if (!sourceLabel) return;
15
+ const source: Source = sourceLabel === 'Claude Code' ? 'claude' : sourceLabel === 'Codex' ? 'codex' : 'chatgpt';
16
+ ctx.ui.setWidget('optchat-import', ['Scanning local conversation metadata…']);
17
+ let scan;
18
+ try {
19
+ if (source === 'chatgpt') {
20
+ const path = await ctx.ui.input('ChatGPT export ZIP, extracted folder, or conversations JSON path', undefined, { signal });
21
+ if (!path?.trim()) return;
22
+ scan = await scanChatGPT(path.trim(), signal);
23
+ } else scan = await scanLocal(source, undefined, signal);
24
+ } finally { ctx.ui.setWidget('optchat-import', undefined); }
25
+ let candidates = scan.conversations;
26
+ if (!candidates.length) throw new Error(`No conversations found for this source.${scan.warnings.length ? '\n' + scan.warnings.slice(0, 4).map(clean).join('\n') : ''}`);
27
+ if (source !== 'chatgpt') {
28
+ const counts = new Map<string, number>();
29
+ for (const c of candidates) counts.set(c.project, (counts.get(c.project) ?? 0) + 1);
30
+ const projects = [...counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([p]) => p);
31
+ const selected = await selectMany(ctx.ui, 'Projects', projects, p => `${p.startsWith(homedir() + '/') ? '~' + p.slice(homedir().length) : p} (${counts.get(p)} conversations)`, signal);
32
+ if (!selected) return;
33
+ candidates = candidates.filter(c => selected.includes(c.project));
34
+ }
35
+ const range = await ctx.ui.select('Conversation dates', ['All history', 'Filter by start date'], { signal });
36
+ if (!range) return;
37
+ if (range === 'Filter by start date') {
38
+ const after = await ctx.ui.input('Started on/after YYYY-MM-DD (blank = no lower bound)', undefined, { signal }); if (after === undefined) return;
39
+ const before = await ctx.ui.input('Started on/before YYYY-MM-DD (blank = no upper bound)', undefined, { signal }); if (before === undefined) return;
40
+ for (const day of [after, before]) if (day && (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(day)) || new Date(day).toISOString().slice(0, 10) !== day)) throw new Error('Enter dates as YYYY-MM-DD.');
41
+ if (after && before && after > before) throw new Error('Start date must precede end date.');
42
+ candidates = candidates.filter(c => (!after || c.date.slice(0, 10) >= after) && (!before || c.date.slice(0, 10) <= before));
43
+ }
44
+ if (!candidates.length) throw new Error('No conversations match these dates.');
45
+ const scope = await ctx.ui.select(`${candidates.length} conversations · ${size(candidates.reduce((n, c) => n + c.size, 0))} source files`, ['All matching conversations', 'Choose individual conversations'], { signal });
46
+ if (!scope) return;
47
+ const conversations: Conversation[] | undefined = scope === 'Choose individual conversations'
48
+ ? await selectMany(ctx.ui, 'Conversations', candidates, c => `${c.date.slice(0, 10)} · ${c.title} · ${c.id}`, signal) : candidates;
49
+ if (!conversations) return;
50
+ const entries: ImportedEntry[] = [], warnings = [...scan.warnings];
51
+ try {
52
+ for (const [i, c] of conversations.entries()) {
53
+ ctx.ui.setWidget('optchat-import', [`Reading ${i + 1}/${conversations.length}: ${clean(c.title)}`]);
54
+ const parsed = await readConversation(c, signal);
55
+ for (const entry of parsed.entries) entries.push(entry);
56
+ for (const warning of parsed.warnings) warnings.push(warning);
57
+ }
58
+ } finally { ctx.ui.setWidget('optchat-import', undefined); }
59
+ if (warnings.length) {
60
+ const choice = await ctx.ui.select(`${warnings.length} source issues: unavailable conversations or unsupported records.\n${warnings.slice(0, 4).map(clean).join('\n')}`, ['Cancel', 'Continue with supported records'], { signal });
61
+ if (choice !== 'Continue with supported records') return;
62
+ }
63
+ if (!entries.length) { ctx.ui.notify('No readable messages remain to import. Rescan or choose different conversations.', 'info'); return; }
64
+ const { added, skipped } = deduplicate(memory.root, entries);
65
+ if (!added.length) { ctx.ui.notify(`Nothing new to import (${skipped} messages already present).`, 'info'); return; }
66
+ let mode: ImportMode = 'append';
67
+ if (memory.root.length) {
68
+ const selected = await ctx.ui.select('How should this history join your memory?', [
69
+ `Append · keep ${memory.tree.size} existing summaries`,
70
+ `Rebuild by conversation start date · recompress ${memory.root.length + added.length} messages`,
71
+ ], { signal });
72
+ if (!selected) return;
73
+ mode = selected.startsWith('Rebuild') ? 'rebuild' : 'append';
74
+ }
75
+ const affected = mode === 'rebuild' ? [...memory.root, ...added] : added;
76
+ const inputBytes = affected.reduce((n, e) => n + bytes(e.text), 0);
77
+ let nodes = 0;
78
+ for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
79
+ if (mode === 'append') nodes -= memory.tree.size;
80
+ const preview = `${profile} · ${mode}\nHistorical user messages and final replies; tool activity excluded.\n${conversations.length} conversations selected · ${added.length} new messages · ${skipped} duplicates skipped\n${size(inputBytes)} text to index (~${Math.ceil(inputBytes / 4).toLocaleString()} source tokens; rough estimate)\nCompactor: ${model}\nUp to ${nodes} new summary nodes; small nodes need no model call. Context and retries add usage.\nChatting in this profile pauses until completion or discard. You can pause and resume compression. The previous memory is retained.`;
81
+ if (!await ctx.ui.confirm('Start import?', preview, { signal })) return;
82
+ return { entries, mode };
83
+ }
84
+ export async function showProgress(ctx: { ui: Pick<ExtensionUIContext, 'select' | 'setWidget'> }, job: ImportJob,
85
+ run: (signal: AbortSignal, progress: (value: ImportProgress) => void) => Promise<unknown>, outerSignal: AbortSignal) {
86
+ const controller = new AbortController(), finished = new AbortController();
87
+ const abort = () => controller.abort(); outerSignal.addEventListener('abort', abort, { once: true });
88
+ if (outerSignal.aborted) abort();
89
+ const task = run(controller.signal, p => ctx.ui.setWidget('optchat-import', [
90
+ `${job.mode} import · ${p.messages}/${p.total} messages indexed · ${p.summaries} summary nodes`,
91
+ p.error ? `Retrying: ${clean(p.error)} · you can pause` : 'The original memory stays intact until this finishes.',
92
+ ]));
93
+ // Attach a rejection handler immediately; UI and model failures can happen independently.
94
+ const outcome = task.then(() => ({ complete: true as const }), error => ({ complete: false as const, error })).finally(() => finished.abort());
95
+ try {
96
+ await ctx.ui.select('Import in progress', ['Pause import'], { signal: finished.signal });
97
+ if (!finished.signal.aborted) controller.abort();
98
+ const result = await outcome;
99
+ if (!result.complete && !controller.signal.aborted) throw result.error;
100
+ return result.complete;
101
+ } finally {
102
+ controller.abort(); await outcome;
103
+ outerSignal.removeEventListener('abort', abort); ctx.ui.setWidget('optchat-import', undefined);
104
+ }
105
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { record } from './cache.ts';
5
+ import { atomicWrite } from './profiles.ts';
6
+ import type { Memory } from './memory.ts';
7
+
8
+ interface Arrival { id: string; text: string; date: string }
9
+ /** Durable arrivals bridge the period before a new turn's old-history snapshot is ready. */
10
+ export class Inbox {
11
+ private readonly file: string;
12
+ private items: Arrival[];
13
+ private readonly claimed = new Set<string>();
14
+ constructor(directory: string) {
15
+ this.file = join(directory, 'pending-inputs.json');
16
+ const saved: unknown = existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : [];
17
+ if (!Array.isArray(saved) || !saved.every(v => record(v) && typeof v.id === 'string' && typeof v.text === 'string' && typeof v.date === 'string'))
18
+ throw new Error('Invalid pending input journal.');
19
+ this.items = saved;
20
+ }
21
+ private save() { atomicWrite(this.file, JSON.stringify(this.items)); }
22
+ record(text: string) {
23
+ const item = { id: randomUUID(), text, date: new Date().toISOString() };
24
+ this.items.push(item); this.save(); return item.id;
25
+ }
26
+ claim(text: string) {
27
+ const item = this.items.find(i => !this.claimed.has(i.id) && i.text === text);
28
+ if (!item) return undefined;
29
+ this.claimed.add(item.id); return item.id;
30
+ }
31
+ acknowledge(id: string) {
32
+ this.items = this.items.filter(i => i.id !== id); this.claimed.delete(id); this.save();
33
+ }
34
+ recover(memory: Memory) {
35
+ let count = 0;
36
+ const receipts = new Set(memory.root.map(e => e.receipt));
37
+ for (const item of this.items) {
38
+ if (!receipts.has(item.id)) { memory.append('user', item.text, item.date, item.id); count++; }
39
+ }
40
+ this.items = []; this.claimed.clear(); this.save(); return count;
41
+ }
42
+ }