pi-optchat 0.6.7 → 0.7.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 +46 -20
- package/docs/victor-recipe.md +5 -4
- package/package.json +4 -3
- package/src/agents.ts +88 -41
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +21 -10
- package/src/handoff.ts +2 -2
- package/src/import/job.ts +26 -11
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +75 -21
- package/src/import/ui.ts +1 -1
- package/src/index.ts +76 -36
- package/src/memory.ts +61 -34
- package/src/profiles.ts +33 -14
- package/src/prompts.ts +1 -1
- package/src/report-message.ts +8 -4
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +164 -0
- package/src/settings.ts +48 -0
- package/src/tools.ts +28 -1
- package/src/transcript.ts +15 -4
- package/src/usage.ts +6 -6
- package/src/window-bridge.ts +4 -4
package/src/compactor.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { clampThinkingLevel, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
|
|
2
2
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import { COMPACT } from './prompts.ts';
|
|
5
5
|
import { bytes, NODE, type Compressor } from './memory.ts';
|
|
6
6
|
import { cachePayload, splitView } from './cache.ts';
|
|
7
7
|
import { IMPORT_GUIDANCE } from './import/guidance.ts';
|
|
8
|
+
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
8
9
|
|
|
9
10
|
export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
|
|
10
|
-
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/** A level the model can't take would be sent as no level, which Sonnet 5.5 runs at high effort; Pi's own sessions clamp the same way. */
|
|
12
|
+
export const reasoningFor = (model: Model<Api>, level: ThinkingLevel) => {
|
|
13
|
+
const thinking = clampThinkingLevel(model, level);
|
|
14
|
+
return thinking === 'off' ? undefined : thinking;
|
|
15
|
+
};
|
|
16
|
+
/** The size example: a line of exactly NODE bytes about OptChat itself. Every claim in it is true and timeless (no ids, PRs or decisions),
|
|
17
|
+
* so a summary that copies it states nothing false; the request also fences it off from the input. */
|
|
18
|
+
export const SCALE = 'note: How OptChat memory works. Each message becomes a leaf line: a short message is its own line, a longer one is compressed to about 512 bytes. Adjacent lines merge in pairs into a binary tree: two lines into one line covering both, two of those into one covering four, and so on. The view shows recent messages one per line and older ones more per line, within a fixed byte budget. zoom(id, n) opens line id+n into the two lines it was made from, down to the original message; date(id) tells when it was sent.';
|
|
14
19
|
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
15
20
|
|
|
16
21
|
/** Parallel calls can't read a cache entry that isn't written yet, so one call primes a cold prefix and the rest wait until it answers. */
|
|
@@ -38,24 +43,29 @@ function primeFirst() {
|
|
|
38
43
|
};
|
|
39
44
|
};
|
|
40
45
|
}
|
|
46
|
+
/** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
|
|
41
47
|
export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
|
|
42
|
-
onUsage: (message: AssistantMessage) => void = () => {}): Compressor {
|
|
48
|
+
onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes): Compressor {
|
|
43
49
|
const gate = primeFirst();
|
|
44
50
|
return async (input, signal) => {
|
|
45
51
|
const selected = choice();
|
|
46
52
|
const model = registry.find(selected.provider, selected.model);
|
|
47
53
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
48
|
-
const
|
|
54
|
+
const thinking = reasoningFor(model, selected.thinking);
|
|
55
|
+
// Shown bare between the view and the input, the example was sometimes summarized as if it were chat, so it is labelled and both are tagged.
|
|
56
|
+
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}For scale only, here is an example line, not from this chat; it is exactly 512 bytes and is never part of your input or your line:\n<example>${SCALE}</example>\n\n${input.merge ? 'Merge these two lines into one' : 'Compress this message into one line'}, in at most 512 bytes:\n<input>\n${input.source}\n</input>`;
|
|
49
57
|
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
|
|
50
58
|
const view = splitView(input.context);
|
|
51
|
-
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${
|
|
59
|
+
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
52
60
|
const tries: string[] = [];
|
|
53
61
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
54
62
|
const warmed = prefix ? await gate(prefix, signal) : () => {};
|
|
55
63
|
let reply: AssistantMessage;
|
|
56
64
|
try {
|
|
57
65
|
const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
|
|
58
|
-
|
|
66
|
+
// A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
|
|
67
|
+
sessionId: 'optchat-compactor', transport: 'sse',
|
|
68
|
+
reasoning: thinking, signal, cacheRetention: 'short',
|
|
59
69
|
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
|
|
60
70
|
});
|
|
61
71
|
// The cache entry is usable once the model starts answering.
|
|
@@ -67,7 +77,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
67
77
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
|
|
68
78
|
if (!line) throw new Error('Compactor returned no text.');
|
|
69
79
|
tries.push(line);
|
|
70
|
-
|
|
80
|
+
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
81
|
+
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
71
82
|
messages.push(reply);
|
|
72
83
|
const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
|
|
73
84
|
messages.push({ role: 'user', content: `That line is ${bytes(line)} bytes; the limit is 512. It must end where it is cut here:\n${cut}| ← LIMIT`, timestamp: Date.now() });
|
package/src/handoff.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
|
4
|
-
import type
|
|
4
|
+
import { reasoningFor, type ModelChoice } from './compactor.ts';
|
|
5
5
|
import type { RunInfo } from './runs.ts';
|
|
6
6
|
import { textContent } from './transcript.ts';
|
|
7
7
|
|
|
@@ -53,7 +53,7 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
|
|
|
53
53
|
const reply = await registry.streamSimple(model, {
|
|
54
54
|
systemPrompt: SYSTEM,
|
|
55
55
|
messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
|
|
56
|
-
}, { reasoning:
|
|
56
|
+
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
|
|
57
57
|
usage(reply);
|
|
58
58
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
|
|
59
59
|
if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
|
package/src/import/job.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { Memory, bytes, type Entry, type Compressor } from '../memory.ts';
|
|
4
|
+
import { Memory, bytes, isEntry, type Entry, type Compressor } from '../memory.ts';
|
|
5
5
|
import { atomicWrite } from '../profiles.ts';
|
|
6
6
|
import { record } from '../cache.ts';
|
|
7
|
-
import type
|
|
7
|
+
import { copyKey, type ImportedEntry } from './sources.ts';
|
|
8
8
|
|
|
9
9
|
export type ImportMode = 'append' | 'rebuild';
|
|
10
10
|
export interface ImportJob {
|
|
@@ -12,6 +12,8 @@ export interface ImportJob {
|
|
|
12
12
|
added: number; skipped: number; total: number; inputBytes: number;
|
|
13
13
|
}
|
|
14
14
|
const pendingFile = (dir: string) => join(dir, 'imports', 'pending.json');
|
|
15
|
+
// The whole planned log. `main/` holds only the part already given to Memory, so resume continues from its length.
|
|
16
|
+
const STAGED = 'staged.jsonl';
|
|
15
17
|
function generationPath(dir: string, name: string) {
|
|
16
18
|
if (name !== '.' && !/^memories\/[a-f0-9-]{36}$/.test(name)) throw new Error('Invalid memory generation path.');
|
|
17
19
|
return join(dir, name);
|
|
@@ -38,11 +40,13 @@ export function pendingImport(dir: string): ImportJob | undefined {
|
|
|
38
40
|
}
|
|
39
41
|
export function deduplicate(existing: readonly Entry[], incoming: readonly ImportedEntry[]) {
|
|
40
42
|
const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
|
|
43
|
+
const copies = new Set(existing.map(copyKey).filter(Boolean));
|
|
41
44
|
const added: ImportedEntry[] = []; let skipped = 0;
|
|
42
45
|
for (const entry of incoming) {
|
|
43
46
|
if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
|
|
44
|
-
|
|
45
|
-
receipts.
|
|
47
|
+
const copy = copyKey(entry);
|
|
48
|
+
if (receipts.has(entry.receipt) || copy && copies.has(copy)) { skipped++; continue; }
|
|
49
|
+
receipts.add(entry.receipt); if (copy) copies.add(copy); added.push(entry);
|
|
46
50
|
}
|
|
47
51
|
return { added, skipped };
|
|
48
52
|
}
|
|
@@ -68,8 +72,10 @@ export function prepareImport(dir: string, old: Memory, incoming: readonly Impor
|
|
|
68
72
|
// Append copies existing summaries without changing their indices. Rebuild regenerates every node.
|
|
69
73
|
const all = mode === 'rebuild' ? chronological([...old.root, ...added]) : [...old.root, ...chronological(added)];
|
|
70
74
|
const entries: Entry[] = all.map((e, i) => ({ ...e, i, size: bytes(`${e.kind}: ${e.text}`) }));
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
const kept = mode === 'append' ? old.root.length : 0;
|
|
76
|
+
for (const sub of ['main', 'tree']) mkdirSync(join(path, sub), { recursive: true, mode: 0o700 });
|
|
77
|
+
if (kept) atomicWrite(join(path, 'main', '000-import.jsonl'), entries.slice(0, kept).map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
78
|
+
atomicWrite(join(path, STAGED), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
73
79
|
if (mode === 'append') cpSync(join(old.directory, 'tree'), join(path, 'tree'), { recursive: true });
|
|
74
80
|
const job: ImportJob = { id, mode, previous: relative(dir, old.directory) || '.', target,
|
|
75
81
|
created: new Date().toISOString(), added: added.length, skipped, total: entries.length,
|
|
@@ -88,17 +94,26 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
|
|
|
88
94
|
progress: (state: ImportProgress) => void = () => {}): Promise<ImportJob> {
|
|
89
95
|
const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
|
|
90
96
|
const path = generationPath(dir, job.target);
|
|
91
|
-
|
|
97
|
+
const finish = () => { rmSync(join(path, STAGED), { force: true }); rmSync(pendingFile(dir)); };
|
|
98
|
+
// A crash after the pointer swap leaves only the cleanup to do.
|
|
99
|
+
if (memoryDirectory(dir) === path) { finish(); return job; }
|
|
100
|
+
if (!existsSync(join(path, STAGED))) throw new Error('Import staging data is missing; original memory remains intact.');
|
|
101
|
+
const plan: unknown[] = readFileSync(join(path, STAGED), 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
102
|
+
// Every line must parse, and the plan must be whole and in order, so a damaged plan can't drop messages silently.
|
|
103
|
+
if (!plan.every(isEntry) || plan.length !== job.total || plan.some((e, i) => e.i !== i)) throw new Error('Import staging data is invalid; original memory remains intact.');
|
|
92
104
|
const memory = new Memory(path, compress, () => {});
|
|
93
|
-
const report = () => progress({ messages: memory.root.length - memory.pending, total:
|
|
105
|
+
const report = () => progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
|
|
94
106
|
const timer = setInterval(report, 500);
|
|
95
107
|
try {
|
|
96
|
-
|
|
108
|
+
if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
|
|
109
|
+
report();
|
|
110
|
+
// Recipe §10: imported messages are compressed like any other, so each arrives once the one before it is summarized, as in a live chat.
|
|
111
|
+
for (const e of plan.slice(memory.root.length)) { await memory.settle(signal); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
|
|
112
|
+
await memory.settle(signal, true); signal.throwIfAborted();
|
|
97
113
|
// Close all writers before the single atomic pointer swap. The previous generation stays intact.
|
|
98
114
|
await memory.close();
|
|
99
115
|
atomicWrite(join(dir, 'imports', `${job.id}.json`), JSON.stringify({ ...job, completed: new Date().toISOString() }, null, 2));
|
|
100
116
|
atomicWrite(join(dir, 'active-memory.json'), JSON.stringify(job.target));
|
|
101
|
-
|
|
102
|
-
report(); return job;
|
|
117
|
+
finish(); report(); return job;
|
|
103
118
|
} finally { clearInterval(timer); await memory.close(); }
|
|
104
119
|
}
|
|
@@ -44,8 +44,8 @@ export class MultiSelect implements Component, Focusable {
|
|
|
44
44
|
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish(undefined);
|
|
45
45
|
if (matchesKey(data, 'return')) {
|
|
46
46
|
if (this.selected.size) return this.finish([...this.selected].sort((a, b) => a - b));
|
|
47
|
-
this.error = 'Select at least one item with
|
|
48
|
-
} else if (matchesKey(data, 'space')) {
|
|
47
|
+
this.error = 'Select at least one item with Tab.';
|
|
48
|
+
} else if (matchesKey(data, 'tab') || (matchesKey(data, 'space') && !this.filter.getValue())) {
|
|
49
49
|
const index = this.visible[this.cursor];
|
|
50
50
|
if (index !== undefined) { if (this.selected.has(index)) this.selected.delete(index); else this.selected.add(index); }
|
|
51
51
|
} else if (matchesKey(data, 'ctrl+a')) this.visible.forEach(i => this.selected.add(i));
|
|
@@ -82,7 +82,7 @@ export class MultiSelect implements Component, Focusable {
|
|
|
82
82
|
if (!this.visible.length) lines.push('No matches. Ctrl+U clears the filter.');
|
|
83
83
|
lines.push(color('dim', `${this.visible.length ? this.cursor + 1 : 0}/${this.visible.length} matching · ${this.labels.length} total`));
|
|
84
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'));
|
|
85
|
+
lines.push(color('dim', 'Tab toggle · Space toggle (empty filter) · Enter continue · Esc cancel'));
|
|
86
86
|
lines.push(color('dim', 'Ctrl+A select matches · Ctrl+D clear matches'));
|
|
87
87
|
return lines.map(line => truncateToWidth(line, width));
|
|
88
88
|
}
|
package/src/import/sources.ts
CHANGED
|
@@ -22,10 +22,15 @@ const codexSubagent = (metadata: Record<string, unknown>) => metadata.source ===
|
|
|
22
22
|
|| record(metadata.source) && 'subagent' in metadata.source;
|
|
23
23
|
const missingSource = (error: unknown) => record(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
24
24
|
const missingWarning = (file: string) => `${file}: source file is no longer available; conversation skipped. Rescan to retry if it returns.`;
|
|
25
|
+
const zoneless = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
|
|
26
|
+
// A time without a zone is UTC, not the machine's zone. A number above 1e11 is milliseconds (1e11 seconds is the year 5138).
|
|
25
27
|
export function timestamp(value: unknown, fallback: string): string {
|
|
26
|
-
const time = typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN;
|
|
27
|
-
|
|
28
|
+
const time = typeof value === 'number' ? (value > 1e11 ? value : value * 1000) : typeof value === 'string' ? Date.parse(value.trim().replace(zoneless, '$1T$2Z')) : NaN;
|
|
29
|
+
const date = new Date(time);
|
|
30
|
+
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
|
|
28
31
|
}
|
|
32
|
+
/** Header time: minutes are enough to place a message, and the rest of an ISO stamp costs summary bytes. */
|
|
33
|
+
const minute = (date: string) => `${date.slice(0, 16).replace('T', ' ')}Z`;
|
|
29
34
|
function text(value: unknown): string {
|
|
30
35
|
if (typeof value === 'string') return value;
|
|
31
36
|
if (Array.isArray(value)) return value.map(text).filter(Boolean).join('\n');
|
|
@@ -38,12 +43,46 @@ function text(value: unknown): string {
|
|
|
38
43
|
if (typeof value.content_type === 'string') return `[${value.content_type} attachment; binary content is not imported]`;
|
|
39
44
|
return '';
|
|
40
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Claude Code logs slash commands, `!` shell commands, and their local output as user messages. Returns undefined
|
|
48
|
+
* for anything else, '' for output to drop, or what the user typed (`/name args`, `/name`, `!command`).
|
|
49
|
+
*/
|
|
50
|
+
function claudeCommand(content: string): string | undefined {
|
|
51
|
+
const s = content.trimStart();
|
|
52
|
+
if (/^<(local-command|bash)-(stdout|stderr)>/.test(s)) return '';
|
|
53
|
+
const shell = /^<bash-input>([\s\S]*?)<\/bash-input>/.exec(s)?.[1].trim();
|
|
54
|
+
if (shell !== undefined) return shell && `!${shell}`;
|
|
55
|
+
if (!/^<command-(name|message|args)>/.test(s)) return undefined;
|
|
56
|
+
const name = /<command-name>([^<]*)<\/command-name>/.exec(s)?.[1].trim(), args = /<command-args>([\s\S]*?)<\/command-args>/.exec(s)?.[1].trim();
|
|
57
|
+
return name ? `${name} ${args ?? ''}`.trimEnd() : '';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Context Codex injects as user messages, as codex-rs recognizes it (core/src/context/contextual_user_message.rs).
|
|
61
|
+
* Codex matches marked fragments ignoring case, but the `<external_…>` context, the internal-context source and the warnings exactly.
|
|
62
|
+
*/
|
|
63
|
+
const CODEX_MARKED = /^(?:# AGENTS\.md instructions[\s\S]*<\/INSTRUCTIONS>|<(environment_context|user_shell_command|turn_aborted|subagent_notification|skill|agent_message_board_notification|recommended_plugins|goal_context)>[\s\S]*<\/\1>|<hook_prompt hook_run_id="[^"]+">[\s\S]*<\/hook_prompt>)$/i;
|
|
64
|
+
const CODEX_EXACT = /^(?:<external_([^>]+)>[\s\S]*<\/external_\1>|<codex_internal_context source="[a-z][a-z0-9_]*">[\s\S]*<\/codex_internal_context>|Warning: apply_patch was requested via [\s\S]*Use the apply_patch tool instead of exec_command\.|Warning: (?:Your account was flagged for potentially high-risk cyber activity|The maximum number of unified exec processes you can keep open is)[\s\S]*)$/;
|
|
65
|
+
/** What the user typed in a Codex message: every part except the context Codex injected. */
|
|
66
|
+
const codexTyped = (content: unknown) => (Array.isArray(content) ? content : [content]).map(text)
|
|
67
|
+
.filter(piece => piece && !CODEX_MARKED.test(piece.trim()) && !CODEX_EXACT.test(piece.trim())).join('\n');
|
|
41
68
|
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
69
|
+
/**
|
|
70
|
+
* A resumed Claude Code session copies the earlier messages into its own file, each with its uuid and text, so a copy
|
|
71
|
+
* matches on those across files. The header line names the file (and its format has changed), and a command stored raw
|
|
72
|
+
* by an older import is read as typed, so entries already in a profile match too. Messages without a uuid never match.
|
|
73
|
+
*/
|
|
74
|
+
export function copyKey(e: Pick<ImportedEntry, 'kind' | 'text' | 'origin'>): string | undefined {
|
|
75
|
+
if (e.origin?.source !== 'claude' || e.origin.message.startsWith('line:')) return undefined;
|
|
76
|
+
const body = e.text.slice(e.text.indexOf('\n') + 1);
|
|
77
|
+
return JSON.stringify([e.origin.message, e.kind, claudeCommand(body) || body]);
|
|
78
|
+
}
|
|
42
79
|
function imported(c: Conversation, id: string, kind: Kind, content: string, date: string, identity = content): ImportedEntry | undefined {
|
|
43
80
|
if (!content.trim()) return undefined;
|
|
44
81
|
// Text provenance survives compression. Stable per-message receipts survive moved files and repeated exports.
|
|
45
82
|
const origin: Origin = { source: c.source, conversation: c.id, message: id, title: c.title, project: c.project };
|
|
46
|
-
|
|
83
|
+
// The agent reads only text, so the id's first 13 characters stay in it: enough to find a Claude or Codex transcript by
|
|
84
|
+
// glob. Codex ids are UUIDv7, whose first 8 characters are a coarse timestamp shared by many sessions.
|
|
85
|
+
return { kind, date, origin, text: `[Historical ${c.source} · ${minute(date)} · ${c.id.slice(0, 13)} · ${c.title}]\n${content}`,
|
|
47
86
|
receipt: `import:${digest(JSON.stringify([c.source, c.id, id, kind, identity]))}` };
|
|
48
87
|
}
|
|
49
88
|
async function* jsonLines(file: string, warnings: string[], limit = Infinity, signal?: AbortSignal) {
|
|
@@ -99,8 +138,10 @@ export async function scanLocal(source: 'claude' | 'codex', roots?: string[], si
|
|
|
99
138
|
if (v.type === 'custom-title' || v.type === 'ai-title') title = string(v.customTitle ?? v.aiTitle) ?? title;
|
|
100
139
|
}
|
|
101
140
|
const m = source === 'claude' ? v.message : v.type === 'response_item' ? v.payload : undefined;
|
|
102
|
-
|
|
103
|
-
|
|
141
|
+
const user = record(m) && m.role === 'user' && !title;
|
|
142
|
+
const first = !user ? '' : source === 'claude' ? claudeCommand(text(m.content)) ?? text(m.content) : codexTyped(m.content);
|
|
143
|
+
if (first.trim()) {
|
|
144
|
+
title = first.replace(/\s+/g, ' ').slice(0, 110);
|
|
104
145
|
date = timestamp(v.timestamp, date);
|
|
105
146
|
}
|
|
106
147
|
}
|
|
@@ -188,10 +229,19 @@ async function claudeProject(folder: string, name: string, signal?: AbortSignal)
|
|
|
188
229
|
function frontmatter(content: string) {
|
|
189
230
|
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
|
|
190
231
|
const fields = new Map<string, string>();
|
|
191
|
-
|
|
232
|
+
const lines = match ? match[1].split(/\r?\n/) : [];
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
234
|
// Newer files nest type/modified under `metadata:`; the first occurrence of each key wins.
|
|
193
|
-
const m =
|
|
194
|
-
if (
|
|
235
|
+
const m = /^(\s*)([A-Za-z]+):\s*(.+?)\s*$/.exec(lines[i]);
|
|
236
|
+
if (!m) continue;
|
|
237
|
+
let value: string;
|
|
238
|
+
if (/^[>|][+-]?\d?$/.test(m[3])) { // A folded or literal block is the indented lines after the key.
|
|
239
|
+
let end = i + 1;
|
|
240
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > m[1].length)) end++;
|
|
241
|
+
value = lines.slice(i + 1, end).map(line => line.trim()).join(m[3].startsWith('>') ? ' ' : '\n').trim();
|
|
242
|
+
i = end - 1;
|
|
243
|
+
} else value = unquote(m[3]);
|
|
244
|
+
if (value && !fields.has(m[2])) fields.set(m[2], value);
|
|
195
245
|
}
|
|
196
246
|
return { fields, body: match ? content.slice(match[0].length) : content };
|
|
197
247
|
}
|
|
@@ -217,7 +267,7 @@ async function readMemory(c: Conversation, signal?: AbortSignal): Promise<{ entr
|
|
|
217
267
|
const date = timestamp(fields.get('modified'), modified.toISOString());
|
|
218
268
|
return { warnings: [], entries: [{ kind: 'note', date,
|
|
219
269
|
origin: { source: c.source, conversation: c.id, message: hash.slice(0, 16), title: name, project: c.project },
|
|
220
|
-
text: `[Historical Claude Code memory · ${date} · project ${c.project}${type ? ` · type ${one(type)}` : ''} · ${name}]\n${description ? one(description) + '\n\n' : ''}${body.trim()}`,
|
|
270
|
+
text: `[Historical Claude Code memory · ${minute(date)} · project ${c.project}${type ? ` · type ${one(type)}` : ''} · ${name}]\n${description ? one(description) + '\n\n' : ''}${body.trim()}`,
|
|
221
271
|
receipt: `import:${digest(JSON.stringify([c.source, c.id, hash]))}` }] };
|
|
222
272
|
}
|
|
223
273
|
|
|
@@ -233,16 +283,19 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
|
|
|
233
283
|
const nodes = Object.entries(mapping).filter((pair): pair is [string, Record<string, unknown>] => record(pair[1]));
|
|
234
284
|
nodes.sort((a, b) => timestamp(record(a[1].message) ? a[1].message.create_time : undefined, c.date)
|
|
235
285
|
.localeCompare(timestamp(record(b[1].message) ? b[1].message.create_time : undefined, c.date)));
|
|
236
|
-
const ordered: typeof nodes = [], visited = new Set<string>()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
286
|
+
const ordered: typeof nodes = [], visited = new Set<string>();
|
|
287
|
+
for (const first of nodes) {
|
|
288
|
+
const chain: typeof nodes = [], inChain = new Set<string>();
|
|
289
|
+
let link: (typeof nodes)[number] | undefined = first;
|
|
290
|
+
while (link && !visited.has(link[0])) {
|
|
291
|
+
const [key, node]: (typeof nodes)[number] = link;
|
|
292
|
+
if (inChain.has(key)) throw new Error(`${c.title}: cycle in ChatGPT conversation mapping.`);
|
|
293
|
+
inChain.add(key); chain.push(link);
|
|
294
|
+
const parent = typeof node.parent === 'string' ? mapping[node.parent] : undefined;
|
|
295
|
+
link = typeof node.parent === 'string' && record(parent) ? [node.parent, parent] : undefined;
|
|
296
|
+
}
|
|
297
|
+
for (const done of chain.reverse()) { visited.add(done[0]); ordered.push(done); }
|
|
298
|
+
}
|
|
246
299
|
const selected = new Set<string>();
|
|
247
300
|
let cursor = string(c.exported?.current_node);
|
|
248
301
|
const hasSelectedBranch = !!cursor;
|
|
@@ -317,7 +370,8 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
|
|
|
317
370
|
warnings.push(`${c.file}:${line}: unsupported Claude content block ${String(b.type)} skipped`);
|
|
318
371
|
return [];
|
|
319
372
|
});
|
|
320
|
-
|
|
373
|
+
// Receipts keep the raw text, so a command already imported is still recognized.
|
|
374
|
+
if (m.role === 'user' && parts.length) { finish(); for (const part of parts) add(part.id, 'user', claudeCommand(part.content) ?? part.content, date, part.content); }
|
|
321
375
|
else if (m.role === 'assistant') {
|
|
322
376
|
const final = m.stop_reason === 'end_turn' || m.stop_reason === 'stop_sequence';
|
|
323
377
|
if (toolActivity || v.isApiErrorMessage === true || m.stop_reason && !final) pending = [];
|
|
@@ -332,7 +386,7 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
|
|
|
332
386
|
}
|
|
333
387
|
if (c.source === 'codex' && v.type === 'response_item' && record(v.payload)) {
|
|
334
388
|
const m = v.payload, id = string(m.id) ?? string(m.call_id) ?? `line:${line}`;
|
|
335
|
-
if (m.type === 'message' && m.role === 'user') { finish(); add(id, 'user',
|
|
389
|
+
if (m.type === 'message' && m.role === 'user') { finish(); add(id, 'user', codexTyped(m.content), date, text(m.content)); }
|
|
336
390
|
else if (m.type === 'message' && m.role === 'assistant') {
|
|
337
391
|
pending = [];
|
|
338
392
|
const channel = m.channel ?? m.phase;
|
package/src/import/ui.ts
CHANGED
|
@@ -78,7 +78,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
78
78
|
let nodes = 0;
|
|
79
79
|
for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
|
|
80
80
|
if (mode === 'append') nodes -= memory.tree.size;
|
|
81
|
-
const preview = `${profile} · ${mode}\n${source === 'claude-memory' ? 'Each memory file as one dated historical note; MEMORY.md indexes excluded.' : 'Historical user messages and final replies; tool activity excluded.'}\n${conversations.length} ${unit} selected · ${added.length} new ${source === 'claude-memory' ? 'notes' : '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.
|
|
81
|
+
const preview = `${profile} · ${mode}\n${source === 'claude-memory' ? 'Each memory file as one dated historical note; MEMORY.md indexes excluded.' : 'Historical user messages and final replies; tool activity excluded.'}\n${conversations.length} ${unit} selected · ${added.length} new ${source === 'claude-memory' ? 'notes' : '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. A big import costs about 3x the source tokens in compactor input, plus cached context and retries.\nChatting in this profile pauses until completion or discard. You can pause and resume compression. The previous memory is retained.`;
|
|
82
82
|
if (!await ctx.ui.confirm('Start import?', preview, { signal })) return;
|
|
83
83
|
return { entries, mode };
|
|
84
84
|
}
|