pi-optchat 0.5.0 → 0.6.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 +7 -5
- package/package.json +1 -1
- package/src/agents.ts +7 -3
- package/src/browser.ts +225 -16
- package/src/import/guidance.ts +1 -1
- package/src/import/sources.ts +77 -0
- package/src/import/ui.ts +13 -12
- package/src/index.ts +5 -2
- package/src/inspector.ts +58 -19
- package/src/memory.ts +1 -1
- package/src/navigation.ts +8 -5
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A Pi extension that implements [Victor Taelin's OptChat recipe](https://gist.git
|
|
|
5
5
|
- **Memory**: every message is logged and summarized into a binary tree. Each turn starts from a fresh context holding a bounded memory view; the agent uses `zoom` and `date` to read originals.
|
|
6
6
|
- **Profiles**: separate memories and instructions, such as `work` and `personal`.
|
|
7
7
|
- **Subagents**: delegate tasks to background agents, inspect them live, and send them guidance.
|
|
8
|
-
- **Import**: bring in history from Claude Code, Codex, or ChatGPT.
|
|
8
|
+
- **Import**: bring in history from Claude Code (conversations and memories), Codex, or ChatGPT.
|
|
9
9
|
- **Connected windows**: a second Pi window on the same profile becomes a subagent you talk to directly.
|
|
10
10
|
|
|
11
11
|
It runs inside ordinary Pi, with no fork or separate launcher.
|
|
@@ -46,7 +46,7 @@ For headless use, pass `--optchat-profile work`.
|
|
|
46
46
|
| `/optchat agents model` | Subagent model and effort for this profile. |
|
|
47
47
|
| `/optchat usage` | Token usage and cost estimates. |
|
|
48
48
|
| `/optchat instructions` | Edit this profile's `AGENTS.md`. |
|
|
49
|
-
| `/optchat browse` | Open
|
|
49
|
+
| `/optchat browse` | Open a readable snapshot of memory: the shape of what the model sees, summaries you can open down to the original messages, and search that shows where each message is folded. Run again to refresh. |
|
|
50
50
|
| `/optchat import` | Import history, or resume/discard a paused import. |
|
|
51
51
|
| `/complete` | In a connected window: end the conversation and hand off to the main agent. |
|
|
52
52
|
| `/tell-main <message>` | In a connected window: message the main agent. |
|
|
@@ -112,8 +112,8 @@ Guidance shows as queued until delivered, or undelivered if the child stops firs
|
|
|
112
112
|
|
|
113
113
|
Pick the destination profile, then run `/optchat import`.
|
|
114
114
|
|
|
115
|
-
1. **Source**: Claude Code (`~/.claude/projects`), Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), or a ChatGPT export (ZIP, folder, or `conversations.json`; ZIP needs `unzip`). Scanning is local and makes no model calls.
|
|
116
|
-
2. **Select**: for Claude Code and Codex, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Space** toggles, **Enter** continues, type to filter, **Ctrl+A**/**Ctrl+D** select/clear matches, **Esc** cancels. Nothing is classified as work or personal for you.
|
|
115
|
+
1. **Source**: Claude Code (`~/.claude/projects`), Claude Code memories, Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), or a ChatGPT export (ZIP, folder, or `conversations.json`; ZIP needs `unzip`). Scanning is local and makes no model calls.
|
|
116
|
+
2. **Select**: for Claude Code, its memories, and Codex, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Space** toggles, **Enter** continues, type to filter, **Ctrl+A**/**Ctrl+D** select/clear matches, **Esc** cancels. Nothing is classified as work or personal for you.
|
|
117
117
|
3. **Mode** (only if the profile already has history):
|
|
118
118
|
- **Append**: keep existing summaries and add the import. Faster and cheaper.
|
|
119
119
|
- **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
|
|
@@ -121,6 +121,8 @@ Pick the destination profile, then run `/optchat import`.
|
|
|
121
121
|
|
|
122
122
|
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
123
123
|
|
|
124
|
+
**Claude Code memories**: the auto-memory topic files in `~/.claude/projects/*/memory/` (not `MEMORY.md`, which only indexes them), picked by project. Each file becomes one dated note in the memory tree, not part of the prompt. An edited file comes in again as a newer note.
|
|
125
|
+
|
|
124
126
|
**Duplicates**: re-importing skips messages already present, even if titles or paths changed. Changed source messages can appear as a separate historical version.
|
|
125
127
|
|
|
126
128
|
**Pausing**: **Pause import** (or Escape) saves progress, and so does restarting Pi. `/optchat import` then offers **Resume** or **Discard staged import**. While an import is pending, chat in that profile is blocked; other profiles still work. Imports need the main agent and its subagents to be idle.
|
|
@@ -171,7 +173,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
171
173
|
|
|
172
174
|
- **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
|
|
173
175
|
- **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
|
|
174
|
-
- **Instructions**:
|
|
176
|
+
- **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only.
|
|
175
177
|
- **Images** are available during the current run but stored in memory as text placeholders.
|
|
176
178
|
- **Pi's auto-compaction is off.** A single very long run can still hit the model's context limit; stop it and continue in a new turn.
|
|
177
179
|
- **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -115,16 +115,20 @@ export class Children {
|
|
|
115
115
|
if (cancelled()) throw new Error('Parent or profile is stopping.');
|
|
116
116
|
const id = randomUUID().slice(0, 8), directory = task.cwd ?? cwd;
|
|
117
117
|
const delegation = depth < 3 ? 'You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
118
|
-
const
|
|
118
|
+
const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_main for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.' : ''].filter(Boolean).join('\n\n');
|
|
119
119
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
120
120
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
121
121
|
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
122
|
-
|
|
122
|
+
noPromptTemplates: true,
|
|
123
123
|
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
124
124
|
extensionFactories: [pi => {
|
|
125
125
|
const provider = this.registry.getRegisteredProviderConfig(selected.provider);
|
|
126
126
|
if (provider) pi.registerProvider(selected.provider, provider);
|
|
127
|
-
|
|
127
|
+
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
128
|
+
pi.on('before_agent_start', event => {
|
|
129
|
+
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
130
|
+
event.systemPromptOptions.sections.instructions = instructions;
|
|
131
|
+
});
|
|
128
132
|
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
129
133
|
}],
|
|
130
134
|
});
|
package/src/browser.ts
CHANGED
|
@@ -2,21 +2,230 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { atomicWrite } from './profiles.ts';
|
|
3
3
|
import { type Memory } from './memory.ts';
|
|
4
4
|
|
|
5
|
+
// One self-contained, read-only HTML snapshot of a profile's memory.
|
|
5
6
|
export function exportBrowser(memory: Memory, profile: string, directory = memory.directory) {
|
|
6
|
-
const data = JSON.stringify({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
const data = JSON.stringify({
|
|
8
|
+
profile, budget: memory.budget, size: memory.size, view: memory.view,
|
|
9
|
+
root: memory.root.map(({ i, kind, text, date, origin }) => ({ i, kind, text, date, ...(origin ? { origin: { source: origin.source, title: origin.title } } : {}) })),
|
|
10
|
+
tree: [...memory.tree.values()].map(({ l, i, text }) => ({ l, i, text })),
|
|
11
|
+
}).replace(/</g, '\\u003c');
|
|
12
|
+
const path = join(directory, 'memory.html'); atomicWrite(path, PAGE.replace('__DATA__', () => data)); return path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Client code avoids backticks and template placeholders so it can live in String.raw.
|
|
16
|
+
const PAGE = String.raw`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OptChat memory</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root{--bg:#f7f3ea;--paper:#fffdf8;--ink:#2a2723;--muted:#6b6357;--rule:#e4dccd;--accent:#7d5630;--bar:#d5c7ad;--you:#3f6380;--talk:#2a2723;--work:#4c6a41;--tool:#6b6357;--note:#80601f;--hl:#f6e7c1}
|
|
19
|
+
*{box-sizing:border-box}
|
|
20
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
|
21
|
+
.wrap{max-width:780px;margin:0 auto;padding:64px 28px 160px}
|
|
22
|
+
h1{font:400 36px/1.15 "Iowan Old Style",Charter,Georgia,serif;margin:0 0 4px}
|
|
23
|
+
h1 small{font-size:18px;color:var(--muted);margin-left:10px}
|
|
24
|
+
.lede{color:var(--muted);margin:0 0 4px}
|
|
25
|
+
.explain{font-size:14px;color:var(--muted);margin:16px 0 0;max-width:620px}
|
|
26
|
+
.shape{display:flex;align-items:flex-end;gap:1px;height:84px;margin:36px 0 6px}
|
|
27
|
+
.shape.dense{gap:0}
|
|
28
|
+
.shape button{flex:1 1 0;min-width:0;border:0;padding:0;background:var(--bar);border-radius:1px;cursor:pointer}
|
|
29
|
+
.shape button:hover,.shape button:focus-visible{background:var(--accent);outline:none}
|
|
30
|
+
.axis{display:flex;justify-content:space-between;font-size:12px;color:var(--muted)}
|
|
31
|
+
nav{display:flex;gap:28px;margin:48px 0 8px;border-bottom:1px solid var(--rule)}
|
|
32
|
+
nav button{background:none;border:0;border-bottom:2px solid transparent;margin-bottom:-1px;padding:0 0 10px;font:inherit;color:var(--muted);cursor:pointer}
|
|
33
|
+
nav button.on{color:var(--ink);border-color:var(--ink)}
|
|
34
|
+
h2.day{font-size:12px;font-weight:600;line-height:1;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:44px 0 0;padding-bottom:10px;border-bottom:1px solid var(--rule)}
|
|
35
|
+
.block,.msg{padding:18px 0;border-bottom:1px solid var(--rule)}
|
|
36
|
+
.children .block,.children .msg{border-bottom:0;padding:12px 0}
|
|
37
|
+
.meta,.who{font-size:13px;color:var(--muted)}
|
|
38
|
+
.who b{font-weight:600;letter-spacing:.04em;text-transform:uppercase;font-size:11.5px}
|
|
39
|
+
.msg.you .who b{color:var(--you)}.msg.talk .who b{color:var(--talk)}.msg.work .who b{color:var(--work)}.msg.tool .who b{color:var(--tool)}.msg.note .who b{color:var(--note)}
|
|
40
|
+
.summary{margin:6px 0 8px}
|
|
41
|
+
.pending{color:var(--muted);font-style:italic}
|
|
42
|
+
.text{margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
43
|
+
.clamp{display:-webkit-box;-webkit-line-clamp:7;-webkit-box-orient:vertical;overflow:hidden}
|
|
44
|
+
.children{margin:4px 0 0 2px;padding-left:20px;border-left:1px solid var(--rule)}
|
|
45
|
+
button.link{background:none;border:0;padding:0;font:inherit;font-size:14px;color:var(--accent);cursor:pointer}
|
|
46
|
+
button.link:hover{text-decoration:underline}
|
|
47
|
+
.target{background:var(--hl);box-shadow:-12px 0 0 var(--hl),12px 0 0 var(--hl)}
|
|
48
|
+
input[type=search]{width:100%;margin:24px 0 8px;padding:12px 16px;font:inherit;border:1px solid var(--rule);border-radius:10px;background:var(--paper);color:var(--ink);outline:none}
|
|
49
|
+
input[type=search]:focus{border-color:var(--bar)}
|
|
50
|
+
.count{font-size:14px;color:var(--muted);margin:0 0 8px}
|
|
51
|
+
mark{background:var(--hl);color:inherit;border-radius:2px;padding:0 1px}
|
|
52
|
+
.path{margin-top:10px;font-size:13px;color:var(--muted)}
|
|
53
|
+
.path .steps{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:6px}
|
|
54
|
+
.path .steps button{font:inherit;font-size:12px;padding:2px 9px;border:1px solid var(--rule);border-radius:99px;background:var(--paper);color:var(--ink);cursor:pointer}
|
|
55
|
+
.path .steps button.on{border-color:var(--accent);color:var(--accent)}
|
|
56
|
+
.peek{margin:8px 0 0;padding:10px 14px;background:var(--paper);border:1px solid var(--rule);border-radius:8px;font-size:14px;color:var(--ink)}
|
|
57
|
+
.empty{color:var(--muted);margin-top:40px}
|
|
58
|
+
</style>
|
|
59
|
+
<div class="wrap">
|
|
60
|
+
<header><h1>Memory <small id="profile"></small></h1><p class="lede" id="lede"></p>
|
|
61
|
+
<p class="explain">Recent messages are kept one by one. Older ones are folded into summaries of 2, 4, 8 or more messages, so the whole history fits in what the model reads each turn. Open a summary to see the two halves it was made from, down to the original messages.</p>
|
|
62
|
+
<div class="shape" id="shape"></div><div class="axis"><span id="from"></span><span>Each bar is one block the model sees. Taller bars fold more messages.</span><span>now</span></div></header>
|
|
63
|
+
<nav><button id="tab-view" class="on">What the model sees</button><button id="tab-search">Search</button></nav>
|
|
64
|
+
<main id="view"></main>
|
|
65
|
+
<main id="search" hidden><input type="search" id="q" placeholder="Search every message" autocomplete="off"><p class="count" id="count"></p><div id="hits"></div></main>
|
|
66
|
+
</div>
|
|
67
|
+
<script type="application/json" id="data">__DATA__</script>
|
|
68
|
+
<script>
|
|
69
|
+
const D = JSON.parse(document.getElementById('data').textContent);
|
|
70
|
+
const nodes = new Map(D.tree.map(n => [n.l + ':' + n.i, n]));
|
|
71
|
+
const $ = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
72
|
+
const first = p => p.i * 2 ** p.l, count = p => 2 ** p.l;
|
|
73
|
+
const year = new Date().getFullYear();
|
|
74
|
+
const day = d => d.toLocaleDateString(undefined, Object.assign({ weekday: 'short', day: 'numeric', month: 'short' }, d.getFullYear() !== year ? { year: 'numeric' } : {}));
|
|
75
|
+
const time = d => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
76
|
+
const times = D.root.map(e => new Date(e.date).getTime()), at = i => new Date(times[i]);
|
|
77
|
+
// Imported history is not always in date order, so a range shows its earliest and latest message.
|
|
78
|
+
function when(a, b) {
|
|
79
|
+
let lo = times[a], hi = times[a];
|
|
80
|
+
for (let k = a + 1; k <= b; k++) { lo = Math.min(lo, times[k]); hi = Math.max(hi, times[k]); }
|
|
81
|
+
const x = new Date(lo), y = new Date(hi);
|
|
82
|
+
if (lo === hi) return day(x) + ', ' + time(x);
|
|
83
|
+
return x.toDateString() === y.toDateString() ? day(x) + ', ' + time(x) + '–' + time(y) : day(x) + ' ' + time(x) + ' – ' + day(y) + ' ' + time(y);
|
|
84
|
+
}
|
|
85
|
+
const SOURCE = { claude: 'Claude Code', 'claude-memory': 'Claude Code memory', codex: 'Codex', chatgpt: 'ChatGPT' };
|
|
86
|
+
// Imported text starts with a "[Historical …]" line for the model; the meta line already says it.
|
|
87
|
+
const body = e => { const j = e.origin && e.text.startsWith('[Historical ') ? e.text.indexOf(']\n') : -1; return j < 0 ? e.text : e.text.slice(j + 2); };
|
|
88
|
+
const short = (s, n) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
89
|
+
function who(e) {
|
|
90
|
+
if (e.kind === 'user') return /^\[[0-9a-f]{6,}\] /.test(e.text) ? ['Subagent', 'work', 'subagent report'] : ['You', 'you', 'from you'];
|
|
91
|
+
if (e.kind === 'talk') return ['OptChat', 'talk', 'reply'];
|
|
92
|
+
if (e.kind === 'tool') return ['Tool call', 'tool', 'tool step'];
|
|
93
|
+
if (e.kind === 'echo') return ['Tool result', 'tool', 'tool step'];
|
|
94
|
+
return ['Note', 'note', 'note'];
|
|
95
|
+
}
|
|
96
|
+
function mix(a, b) {
|
|
97
|
+
const c = new Map();
|
|
98
|
+
for (let k = a; k < b; k++) { const w = who(D.root[k])[2]; c.set(w, (c.get(w) || 0) + 1); }
|
|
99
|
+
return [...c].map(([w, n]) => n.toLocaleString() + ' ' + (n === 1 || w === 'from you' ? w : w === 'reply' ? 'replies' : w + 's')).join(', ');
|
|
100
|
+
}
|
|
101
|
+
function head(e) {
|
|
102
|
+
const [label] = who(e), h = $('div', 'who');
|
|
103
|
+
h.append($('b', null, label), ' · ' + when(e.i, e.i));
|
|
104
|
+
if (e.origin) h.append(' · imported from ' + SOURCE[e.origin.source] + (e.origin.title ? ': ' + short(e.origin.title, 60) : ''));
|
|
105
|
+
return h;
|
|
106
|
+
}
|
|
107
|
+
function message(e) {
|
|
108
|
+
const text = body(e), el = $('div', 'msg ' + who(e)[1]), t = $('div', 'text clamp', text);
|
|
109
|
+
el.dataset.k = '0:' + e.i; el.append(head(e), t);
|
|
110
|
+
if (text.length > 600 || text.split('\n').length > 7) {
|
|
111
|
+
const b = $('button', 'link', 'Show all');
|
|
112
|
+
b.onclick = () => { b.textContent = t.classList.toggle('clamp') ? 'Show all' : 'Show less'; };
|
|
113
|
+
el.append(b);
|
|
114
|
+
} else t.classList.remove('clamp');
|
|
115
|
+
return el;
|
|
116
|
+
}
|
|
117
|
+
// The model sees a large message as its shortened node, so the view shows that and keeps the original one click away.
|
|
118
|
+
function leaf(p) {
|
|
119
|
+
const e = D.root[p.i], n = nodes.get('0:' + p.i);
|
|
120
|
+
if (!n || n.text === e.text) return message(e);
|
|
121
|
+
const el = $('article', 'block'), kids = $('div', 'children'), b = $('button', 'link', 'Show the original');
|
|
122
|
+
el.dataset.k = '0:' + p.i; kids.hidden = true;
|
|
123
|
+
el.append($('div', 'meta', who(e)[0] + ' · ' + when(p.i, p.i) + ' · shortened for the model'), $('p', 'summary', n.text), b, kids);
|
|
124
|
+
b.onclick = () => { if (!kids.childElementCount) kids.append(message(e)); kids.hidden = !kids.hidden; b.textContent = kids.hidden ? 'Show the original' : 'Close'; };
|
|
125
|
+
return el;
|
|
126
|
+
}
|
|
127
|
+
function block(p) {
|
|
128
|
+
if (p.l === 0) return message(D.root[p.i]);
|
|
129
|
+
const a = first(p), n = nodes.get(p.l + ':' + p.i), el = $('article', 'block');
|
|
130
|
+
el.dataset.k = p.l + ':' + p.i;
|
|
131
|
+
el.append($('div', 'meta', when(a, a + count(p) - 1) + ' · ' + count(p).toLocaleString() + ' messages: ' + mix(a, a + count(p))));
|
|
132
|
+
el.append(n ? $('p', 'summary', n.text) : $('p', 'summary pending', 'Not summarized yet.'));
|
|
133
|
+
const b = $('button', 'link', 'Open the two halves'), kids = $('div', 'children');
|
|
134
|
+
kids.hidden = true; b.onclick = () => open(el); el.append(b, kids);
|
|
135
|
+
return el;
|
|
136
|
+
}
|
|
137
|
+
function open(el, force) {
|
|
138
|
+
const [l, i] = el.dataset.k.split(':').map(Number), kids = el.querySelector(':scope > .children'), b = el.querySelector(':scope > button.link');
|
|
139
|
+
if (!kids.childElementCount) kids.append(block({ l: l - 1, i: 2 * i }), block({ l: l - 1, i: 2 * i + 1 }));
|
|
140
|
+
kids.hidden = force === undefined ? !kids.hidden : !force;
|
|
141
|
+
b.textContent = kids.hidden ? 'Open the two halves' : 'Close';
|
|
142
|
+
return kids;
|
|
22
143
|
}
|
|
144
|
+
const view = document.getElementById('view'), search = document.getElementById('search');
|
|
145
|
+
function show(tab) {
|
|
146
|
+
view.hidden = tab !== 'view'; search.hidden = tab !== 'search';
|
|
147
|
+
document.getElementById('tab-view').classList.toggle('on', tab === 'view');
|
|
148
|
+
document.getElementById('tab-search').classList.toggle('on', tab === 'search');
|
|
149
|
+
if (tab === 'search') document.getElementById('q').focus();
|
|
150
|
+
}
|
|
151
|
+
document.getElementById('tab-view').onclick = () => show('view');
|
|
152
|
+
document.getElementById('tab-search').onclick = () => show('search');
|
|
153
|
+
function target(el) { document.querySelectorAll('.target').forEach(x => x.classList.remove('target')); el.classList.add('target'); el.scrollIntoView({ block: 'center' }); }
|
|
154
|
+
const holder = m => D.view.find(p => first(p) <= m && m < first(p) + count(p));
|
|
155
|
+
function reveal(m) {
|
|
156
|
+
show('view');
|
|
157
|
+
const p = holder(m); let el = view.querySelector('[data-k="' + p.l + ':' + p.i + '"]');
|
|
158
|
+
for (let k = p.l; k > 0; k--) el = open(el, true).querySelector(':scope > [data-k="' + (k - 1) + ':' + Math.floor(m / 2 ** (k - 1)) + '"]');
|
|
159
|
+
if (el.matches('article')) { const b = el.querySelector(':scope > button.link'); if (b.textContent !== 'Close') b.click(); el = el.querySelector('.children > .msg'); }
|
|
160
|
+
target(el);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
document.getElementById('profile').textContent = D.profile;
|
|
164
|
+
if (!D.root.length) { document.getElementById('lede').textContent = 'No messages yet.'; document.querySelector('.shape').remove(); document.querySelector('.axis').remove(); }
|
|
165
|
+
else {
|
|
166
|
+
const kb = n => Math.round(n / 1000).toLocaleString() + ' KB';
|
|
167
|
+
document.getElementById('lede').textContent = D.root.length.toLocaleString() + ' messages since ' + day(at(0)) + '. The model sees them as ' + D.view.length.toLocaleString() + ' blocks, using ' + kb(D.size) + ' of its ' + kb(D.budget) + ' memory view.';
|
|
168
|
+
document.getElementById('from').textContent = day(at(0));
|
|
169
|
+
const shape = document.getElementById('shape'), top = Math.max(1, ...D.view.map(p => p.l));
|
|
170
|
+
shape.classList.toggle('dense', D.view.length > 250);
|
|
171
|
+
for (const p of D.view) {
|
|
172
|
+
const s = $('button'), a = first(p);
|
|
173
|
+
s.style.height = (6 + 78 * p.l / top) + 'px';
|
|
174
|
+
s.title = when(a, a + count(p) - 1) + ' · ' + count(p).toLocaleString() + (count(p) === 1 ? ' message' : ' messages');
|
|
175
|
+
s.onclick = () => { show('view'); target(view.querySelector('[data-k="' + p.l + ':' + p.i + '"]')); };
|
|
176
|
+
s.setAttribute('aria-label', s.title);
|
|
177
|
+
shape.append(s);
|
|
178
|
+
}
|
|
179
|
+
let last = '';
|
|
180
|
+
for (const p of D.view) {
|
|
181
|
+
const d = at(first(p));
|
|
182
|
+
if (d.toDateString() !== last) { view.append($('h2', 'day', day(d))); last = d.toDateString(); }
|
|
183
|
+
view.append(p.l ? block(p) : leaf(p));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const q = document.getElementById('q'), hits = document.getElementById('hits'), counter = document.getElementById('count');
|
|
188
|
+
const lower = D.root.map(e => body(e).toLowerCase());
|
|
189
|
+
function snippet(text, j, len) {
|
|
190
|
+
const a = Math.max(0, j - 140), b = Math.min(text.length, j + len + 220), el = $('div', 'text');
|
|
191
|
+
el.append((a ? '…' : '') + text.slice(a, j), $('mark', null, text.slice(j, j + len)), text.slice(j + len, b) + (b < text.length ? '…' : ''));
|
|
192
|
+
return el;
|
|
193
|
+
}
|
|
194
|
+
function path(m) {
|
|
195
|
+
const p = holder(m), wrap = $('div', 'path'), steps = $('div', 'steps'), peek = $('div', 'peek');
|
|
196
|
+
wrap.append(p.l ? 'The model sees this inside a ' + count(p).toLocaleString() + '-message summary. Each step is one zoom:' : 'The model sees this message on its own.');
|
|
197
|
+
const go = $('button', 'link', 'Show in memory'); go.onclick = () => reveal(m);
|
|
198
|
+
if (!p.l) { wrap.append(' ', go); return wrap; }
|
|
199
|
+
peek.hidden = true; go.style.marginLeft = '8px';
|
|
200
|
+
for (let k = p.l; k >= 0; k--) {
|
|
201
|
+
const i = Math.floor(m / 2 ** k), b = $('button', null, k ? (2 ** k).toLocaleString() + ' messages' : 'this message');
|
|
202
|
+
b.onclick = () => {
|
|
203
|
+
const on = !b.classList.contains('on'); steps.querySelectorAll('button').forEach(x => x.classList.remove('on'));
|
|
204
|
+
b.classList.toggle('on', on); peek.hidden = !on;
|
|
205
|
+
peek.textContent = (nodes.get(k + ':' + i) || { text: 'Not summarized yet.' }).text;
|
|
206
|
+
};
|
|
207
|
+
steps.append(b);
|
|
208
|
+
if (k) steps.append('›');
|
|
209
|
+
}
|
|
210
|
+
steps.append(go);
|
|
211
|
+
wrap.append(steps, peek);
|
|
212
|
+
return wrap;
|
|
213
|
+
}
|
|
214
|
+
let timer;
|
|
215
|
+
q.oninput = () => { clearTimeout(timer); timer = setTimeout(find, 120); };
|
|
216
|
+
function find() {
|
|
217
|
+
const s = q.value.trim().toLowerCase();
|
|
218
|
+
hits.replaceChildren(); counter.textContent = '';
|
|
219
|
+
if (s.length < 2) return;
|
|
220
|
+
const found = [];
|
|
221
|
+
for (let i = D.root.length - 1; i >= 0; i--) { const j = lower[i].indexOf(s); if (j >= 0) found.push([i, j]); }
|
|
222
|
+
// Imported history is appended after native messages and may be out of date order, so sort by time.
|
|
223
|
+
found.sort((x, y) => times[y[0]] - times[x[0]] || y[0] - x[0]);
|
|
224
|
+
counter.textContent = found.length ? found.length.toLocaleString() + (found.length === 1 ? ' message' : ' messages') + ', newest first' + (found.length > 100 ? ' (showing 100)' : '') : 'No messages match.';
|
|
225
|
+
for (const [i, j] of found.slice(0, 100)) {
|
|
226
|
+
const e = D.root[i], el = $('div', 'msg ' + who(e)[1]);
|
|
227
|
+
el.append(head(e), snippet(body(e), j, s.length), path(i));
|
|
228
|
+
hits.append(el);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
</script></html>`;
|
package/src/import/guidance.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const IMPORT_GUIDANCE = 'Entries marked Historical are imported records, not new requests. Preserve their source, original dates, and alternate-branch labels when interpreting or summarizing them. Import order does not determine precedence: newer dated user decisions and current profile instructions take precedence over older imported instructions. An alternate branch is not the selected outcome. Branch selection markers describe the export snapshot, not a new user decision. Do not execute historical requests unless the user asks you to resume them.';
|
|
1
|
+
export const IMPORT_GUIDANCE = 'Entries marked Historical are imported records, not new requests. Preserve their source, original dates, and alternate-branch labels when interpreting or summarizing them. Import order does not determine precedence: newer dated user decisions and current profile instructions take precedence over older imported instructions. An alternate branch is not the selected outcome. Branch selection markers describe the export snapshot, not a new user decision. Do not execute historical requests unless the user asks you to resume them. Imported notes describe what was true on their date and may be outdated; verify before relying on them.';
|
package/src/import/sources.ts
CHANGED
|
@@ -145,8 +145,85 @@ export async function scanChatGPT(input: string, signal?: AbortSignal): Promise<
|
|
|
145
145
|
return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Claude Code auto memory: one note per topic file. MEMORY.md is only an index of those files. */
|
|
149
|
+
export async function scanClaudeMemories(root = join(homedir(), '.claude/projects'), signal?: AbortSignal): Promise<Scan> {
|
|
150
|
+
const conversations: Conversation[] = [], warnings: string[] = [];
|
|
151
|
+
const dirs = await readdir(root, { withFileTypes: true }).catch(error => { if (missingSource(error)) return []; throw error; });
|
|
152
|
+
for (const dir of dirs.filter(d => d.isDirectory()).map(d => d.name).sort()) {
|
|
153
|
+
signal?.throwIfAborted();
|
|
154
|
+
const folder = join(root, dir, 'memory');
|
|
155
|
+
const files = (await readdir(folder, { withFileTypes: true }).catch(error => { if (missingSource(error)) return []; throw error; }))
|
|
156
|
+
.filter(f => f.isFile() && f.name.endsWith('.md') && f.name !== 'MEMORY.md').map(f => f.name).sort();
|
|
157
|
+
if (!files.length) continue;
|
|
158
|
+
const project = await claudeProject(join(root, dir), dir, signal);
|
|
159
|
+
for (const name of files) {
|
|
160
|
+
const file = join(folder, name);
|
|
161
|
+
try {
|
|
162
|
+
const [info, content] = await Promise.all([stat(file), readFile(file, { encoding: 'utf8', signal })]);
|
|
163
|
+
const { fields } = frontmatter(content);
|
|
164
|
+
conversations.push({ source: 'claude-memory', id: `${dir}/${name}`, file, project, size: info.size,
|
|
165
|
+
title: fields.get('name') ?? basename(name, '.md'), date: timestamp(fields.get('modified'), info.mtime.toISOString()) });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
signal?.throwIfAborted();
|
|
168
|
+
if (!missingSource(error)) throw error;
|
|
169
|
+
warnings.push(`${file}: memory file is no longer available; skipped.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { conversations: conversations.sort((a, b) => b.date.localeCompare(a.date)), warnings };
|
|
174
|
+
}
|
|
175
|
+
/** Claude names project folders after the launch directory with every other character replaced by '-'. Recover it from a transcript. */
|
|
176
|
+
async function claudeProject(folder: string, name: string, signal?: AbortSignal): Promise<string> {
|
|
177
|
+
const transcripts = (await readdir(folder)).filter(f => f.endsWith('.jsonl')).sort();
|
|
178
|
+
for (const transcript of transcripts) {
|
|
179
|
+
try {
|
|
180
|
+
for await (const { value } of jsonLines(join(folder, transcript), [], 60, signal)) {
|
|
181
|
+
const cwd = string(value.cwd);
|
|
182
|
+
if (cwd && cwd.replace(/[^a-zA-Z0-9]/g, '-') === name) return cwd;
|
|
183
|
+
}
|
|
184
|
+
} catch (error) { signal?.throwIfAborted(); if (!missingSource(error)) throw error; }
|
|
185
|
+
}
|
|
186
|
+
return name; // Transcripts can be cleaned up while memory remains.
|
|
187
|
+
}
|
|
188
|
+
function frontmatter(content: string) {
|
|
189
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
|
|
190
|
+
const fields = new Map<string, string>();
|
|
191
|
+
for (const line of match ? match[1].split(/\r?\n/) : []) {
|
|
192
|
+
// Newer files nest type/modified under `metadata:`; the first occurrence of each key wins.
|
|
193
|
+
const m = /^\s*([A-Za-z]+):\s*(.+?)\s*$/.exec(line);
|
|
194
|
+
if (m && !fields.has(m[1])) fields.set(m[1], unquote(m[2]));
|
|
195
|
+
}
|
|
196
|
+
return { fields, body: match ? content.slice(match[0].length) : content };
|
|
197
|
+
}
|
|
198
|
+
function unquote(value: string): string {
|
|
199
|
+
if (value.startsWith('"')) { try { const parsed: unknown = JSON.parse(value); if (typeof parsed === 'string') return parsed; } catch { /* plain text */ } }
|
|
200
|
+
if (/^'.*'$/.test(value)) return value.slice(1, -1).replaceAll("''", "'");
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
async function readMemory(c: Conversation, signal?: AbortSignal): Promise<{ entries: ImportedEntry[]; warnings: string[] }> {
|
|
204
|
+
let content: string, modified: Date;
|
|
205
|
+
try { [content, { mtime: modified }] = await Promise.all([readFile(c.file, { encoding: 'utf8', signal }), stat(c.file)]); } catch (error) {
|
|
206
|
+
signal?.throwIfAborted();
|
|
207
|
+
if (!missingSource(error)) throw error;
|
|
208
|
+
return { entries: [], warnings: [`${c.file}: memory file is no longer available; skipped.`] };
|
|
209
|
+
}
|
|
210
|
+
const { fields, body } = frontmatter(content);
|
|
211
|
+
if (!body.trim()) return { entries: [], warnings: [] };
|
|
212
|
+
const one = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
213
|
+
const name = one(fields.get('name') ?? c.title), type = fields.get('type'), description = fields.get('description');
|
|
214
|
+
// The whole file is the identity, so an edited memory arrives as a newer note and an unchanged one is skipped.
|
|
215
|
+
const hash = digest(content);
|
|
216
|
+
// Date the note from this read, not the earlier scan, in case Claude edited the file meanwhile.
|
|
217
|
+
const date = timestamp(fields.get('modified'), modified.toISOString());
|
|
218
|
+
return { warnings: [], entries: [{ kind: 'note', date,
|
|
219
|
+
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()}`,
|
|
221
|
+
receipt: `import:${digest(JSON.stringify([c.source, c.id, hash]))}` }] };
|
|
222
|
+
}
|
|
223
|
+
|
|
148
224
|
export async function readConversation(c: Conversation, signal?: AbortSignal): Promise<{ entries: ImportedEntry[]; warnings: string[] }> {
|
|
149
225
|
signal?.throwIfAborted();
|
|
226
|
+
if (c.source === 'claude-memory') return readMemory(c, signal);
|
|
150
227
|
const entries: ImportedEntry[] = [], warnings: string[] = [];
|
|
151
228
|
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
229
|
if (c.source === 'chatgpt') {
|
package/src/import/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { Memory } from '../memory.ts';
|
|
3
3
|
import { bytes } from '../memory.ts';
|
|
4
|
-
import { scanLocal, scanChatGPT, readConversation, type Conversation, type ImportedEntry, type Source } from './sources.ts';
|
|
4
|
+
import { scanLocal, scanChatGPT, scanClaudeMemories, readConversation, type Conversation, type ImportedEntry, type Source } from './sources.ts';
|
|
5
5
|
import { deduplicate, type ImportMode, type ImportJob, type ImportProgress } from './job.ts';
|
|
6
6
|
import { selectMany } from './multi-select.ts';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
@@ -10,9 +10,10 @@ type ImportUI = { ui: Pick<ExtensionUIContext, 'select' | 'input' | 'confirm' |
|
|
|
10
10
|
const clean = (s: string) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
11
11
|
const size = (n: number) => `${(n / 1_000_000).toFixed(1)} MB`;
|
|
12
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 });
|
|
13
|
+
const sourceLabel = await ctx.ui.select(`Import into ${profile} · source`, ['Claude Code', 'Claude Code memories', 'Codex', 'ChatGPT export'], { signal });
|
|
14
14
|
if (!sourceLabel) return;
|
|
15
|
-
const source: Source = sourceLabel === 'Claude Code' ? 'claude' : sourceLabel === 'Codex' ? 'codex' : 'chatgpt';
|
|
15
|
+
const source: Source = sourceLabel === 'Claude Code' ? 'claude' : sourceLabel === 'Claude Code memories' ? 'claude-memory' : sourceLabel === 'Codex' ? 'codex' : 'chatgpt';
|
|
16
|
+
const unit = source === 'claude-memory' ? 'memories' : 'conversations';
|
|
16
17
|
ctx.ui.setWidget('optchat-import', ['Scanning local conversation metadata…']);
|
|
17
18
|
let scan;
|
|
18
19
|
try {
|
|
@@ -20,19 +21,19 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
20
21
|
const path = await ctx.ui.input('ChatGPT export ZIP, extracted folder, or conversations JSON path', undefined, { signal });
|
|
21
22
|
if (!path?.trim()) return;
|
|
22
23
|
scan = await scanChatGPT(path.trim(), signal);
|
|
23
|
-
} else scan = await scanLocal(source, undefined, signal);
|
|
24
|
+
} else scan = source === 'claude-memory' ? await scanClaudeMemories(undefined, signal) : await scanLocal(source, undefined, signal);
|
|
24
25
|
} finally { ctx.ui.setWidget('optchat-import', undefined); }
|
|
25
26
|
let candidates = scan.conversations;
|
|
26
|
-
if (!candidates.length) throw new Error(`No
|
|
27
|
+
if (!candidates.length) throw new Error(`No ${unit} found for this source.${scan.warnings.length ? '\n' + scan.warnings.slice(0, 4).map(clean).join('\n') : ''}`);
|
|
27
28
|
if (source !== 'chatgpt') {
|
|
28
29
|
const counts = new Map<string, number>();
|
|
29
30
|
for (const c of candidates) counts.set(c.project, (counts.get(c.project) ?? 0) + 1);
|
|
30
31
|
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)}
|
|
32
|
+
const selected = await selectMany(ctx.ui, 'Projects', projects, p => `${p.startsWith(homedir() + '/') ? '~' + p.slice(homedir().length) : p} (${counts.get(p)} ${unit})`, signal);
|
|
32
33
|
if (!selected) return;
|
|
33
34
|
candidates = candidates.filter(c => selected.includes(c.project));
|
|
34
35
|
}
|
|
35
|
-
const range = await ctx.ui.select('Conversation dates', ['All history', 'Filter by start date'], { signal });
|
|
36
|
+
const range = await ctx.ui.select(source === 'claude-memory' ? 'Memory dates' : 'Conversation dates', ['All history', 'Filter by start date'], { signal });
|
|
36
37
|
if (!range) return;
|
|
37
38
|
if (range === 'Filter by start date') {
|
|
38
39
|
const after = await ctx.ui.input('Started on/after YYYY-MM-DD (blank = no lower bound)', undefined, { signal }); if (after === undefined) return;
|
|
@@ -41,11 +42,11 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
41
42
|
if (after && before && after > before) throw new Error('Start date must precede end date.');
|
|
42
43
|
candidates = candidates.filter(c => (!after || c.date.slice(0, 10) >= after) && (!before || c.date.slice(0, 10) <= before));
|
|
43
44
|
}
|
|
44
|
-
if (!candidates.length) throw new Error(
|
|
45
|
-
const scope = await ctx.ui.select(`${candidates.length}
|
|
45
|
+
if (!candidates.length) throw new Error(`No ${unit} match these dates.`);
|
|
46
|
+
const scope = await ctx.ui.select(`${candidates.length} ${unit} · ${size(candidates.reduce((n, c) => n + c.size, 0))} source files`, [`All matching ${unit}`, `Choose individual ${unit}`], { signal });
|
|
46
47
|
if (!scope) return;
|
|
47
|
-
const conversations: Conversation[] | undefined = scope ===
|
|
48
|
-
? await selectMany(ctx.ui, 'Conversations', candidates, c => `${c.date.slice(0, 10)} · ${c.title} · ${c.id}`, signal) : candidates;
|
|
48
|
+
const conversations: Conversation[] | undefined = scope === `Choose individual ${unit}`
|
|
49
|
+
? await selectMany(ctx.ui, source === 'claude-memory' ? 'Memories' : 'Conversations', candidates, c => `${c.date.slice(0, 10)} · ${c.title} · ${c.id}`, signal) : candidates;
|
|
49
50
|
if (!conversations) return;
|
|
50
51
|
const entries: ImportedEntry[] = [], warnings = [...scan.warnings];
|
|
51
52
|
try {
|
|
@@ -77,7 +78,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
77
78
|
let nodes = 0;
|
|
78
79
|
for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
|
|
79
80
|
if (mode === 'append') nodes -= memory.tree.size;
|
|
80
|
-
const preview = `${profile} · ${mode}\
|
|
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. 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
82
|
if (!await ctx.ui.confirm('Start import?', preview, { signal })) return;
|
|
82
83
|
return { entries, mode };
|
|
83
84
|
}
|
package/src/index.ts
CHANGED
|
@@ -194,12 +194,15 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
194
194
|
}
|
|
195
195
|
return { action: 'continue' };
|
|
196
196
|
});
|
|
197
|
-
pi.on('before_agent_start', (
|
|
197
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
198
198
|
flush(); run = []; logged = 0; view = undefined; runStarted = true;
|
|
199
199
|
previous = previousExchange(ctx.sessionManager.getBranch());
|
|
200
200
|
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
201
201
|
const a = required();
|
|
202
|
-
|
|
202
|
+
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
203
|
+
event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.`;
|
|
204
|
+
event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
|
|
205
|
+
prompt = event.systemPrompt;
|
|
203
206
|
});
|
|
204
207
|
pi.on('message_end', (event, ctx) => {
|
|
205
208
|
if (!active || !runStarted) return;
|
package/src/inspector.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Input, matchesKey, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
|
|
1
|
+
import { Input, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
4
4
|
import type { Children } from './agents.ts';
|
|
@@ -11,11 +11,24 @@ export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9
|
|
|
11
11
|
export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
12
12
|
export const count = (n: number) => n.toLocaleString('en-US');
|
|
13
13
|
export const elapsed = (ms: number) => ms < 60_000 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.floor(ms / 1000) % 60}s`;
|
|
14
|
+
/** Shortens plain text with an ellipsis, preferring a word boundary. */
|
|
15
|
+
export function fit(text: string, width: number) {
|
|
16
|
+
if (visibleWidth(text) <= width) return text;
|
|
17
|
+
if (width < 2) return sliceByColumn(text, 0, width, true);
|
|
18
|
+
const cut = sliceByColumn(text, 0, width - 1, true), space = cut.lastIndexOf(' '); // Unlike truncateToWidth, adds no style reset.
|
|
19
|
+
return `${(space > cut.length * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
20
|
+
}
|
|
21
|
+
/** Left text and right text on one line, the right side flush with the edge when both fit. */
|
|
22
|
+
const spread = (left: string, right: string, width: number) => {
|
|
23
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
24
|
+
return gap >= 2 ? `${left}${' '.repeat(gap)}${right}` : left;
|
|
25
|
+
};
|
|
14
26
|
export type InspectorPage = 'agents' | 'usage';
|
|
27
|
+
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'border';
|
|
15
28
|
interface Options {
|
|
16
29
|
profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
|
|
17
30
|
rows: () => number; redraw: () => void; done: (action?: 'model') => void;
|
|
18
|
-
color: (tone:
|
|
31
|
+
color: (tone: Tone, text: string) => string;
|
|
19
32
|
context: () => number | null | undefined;
|
|
20
33
|
refreshUsage?: () => void;
|
|
21
34
|
signal?: AbortSignal;
|
|
@@ -30,6 +43,7 @@ export class Inspector implements Component, Focusable {
|
|
|
30
43
|
private top = 0;
|
|
31
44
|
private scroll = 0;
|
|
32
45
|
private lineCount = 0;
|
|
46
|
+
private hintLines = 1;
|
|
33
47
|
private follow = true;
|
|
34
48
|
private details = false;
|
|
35
49
|
private range: UsageRange = 'This session';
|
|
@@ -55,7 +69,8 @@ export class Inspector implements Component, Focusable {
|
|
|
55
69
|
this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.abort);
|
|
56
70
|
}
|
|
57
71
|
invalidate() { this.composer?.invalidate(); }
|
|
58
|
-
|
|
72
|
+
/** Body rows left after the rules, title, hint, spacing and composer; the rest of the screen keeps the footer visible. */
|
|
73
|
+
private get height() { return Math.max(1, Math.floor(this.options.rows() * 0.8) - (this.composer ? 7 : 6) - (this.hintLines - 1)); }
|
|
59
74
|
private select(delta: number) {
|
|
60
75
|
const list = this.options.children.history.list();
|
|
61
76
|
const index = Math.max(0, list.findIndex(r => r.id === this.selected));
|
|
@@ -178,17 +193,18 @@ export class Inspector implements Component, Focusable {
|
|
|
178
193
|
}
|
|
179
194
|
render(width: number): string[] {
|
|
180
195
|
const { color, children, profile } = this.options;
|
|
196
|
+
const inner = Math.max(1, width - 2);
|
|
181
197
|
const inspected = this.opened ? children.history.records.get(this.opened) : undefined;
|
|
182
|
-
|
|
183
|
-
let body: string[], hint: string;
|
|
198
|
+
let title = `OptChat · ${profile} · ${inspected ? `${inspected.state}: ${oneLine(inspected.task)}` : this.page === 'usage' ? 'Usage' : 'Agents'}`;
|
|
199
|
+
let info: string, body: string[], hint: string;
|
|
184
200
|
if (this.page === 'usage' || this.opened) {
|
|
185
201
|
const run = this.opened ? children.history.records.get(this.opened) : undefined;
|
|
186
202
|
const text = this.page === 'usage' ? this.usageLines() : run ? `${run.state} · ${elapsed((run.ended ?? Date.now()) - run.started)} · ${run.id}\n\n${this.transcript(run)}` : 'Run unavailable.';
|
|
187
|
-
const lines = wrapTextWithAnsi(clean(text),
|
|
203
|
+
const lines = wrapTextWithAnsi(clean(text), inner); this.lineCount = lines.length;
|
|
188
204
|
if (this.opened && this.follow) this.scroll = Math.max(0, lines.length - this.height);
|
|
189
205
|
this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
|
|
190
206
|
body = lines.slice(this.scroll, this.scroll + this.height);
|
|
191
|
-
|
|
207
|
+
info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}${this.opened ? this.follow ? ' · following' : ' · scroll paused' : ''}`;
|
|
192
208
|
hint = this.page === 'usage' ? '←→ period · ↑↓/PgUp/PgDn scroll · Tab agents · Esc close' : '↑↓/PgUp/PgDn scroll · t tools · f follow · s message · x stop tree · Esc back';
|
|
193
209
|
} else {
|
|
194
210
|
const list = children.history.list();
|
|
@@ -197,24 +213,47 @@ export class Inspector implements Component, Focusable {
|
|
|
197
213
|
this.top = Math.max(0, Math.min(this.top, list.length - this.height));
|
|
198
214
|
if (cursor < this.top) this.top = cursor;
|
|
199
215
|
if (cursor >= this.top + this.height) this.top = cursor - this.height + 1;
|
|
200
|
-
|
|
216
|
+
const rows = list.slice(this.top, this.top + this.height).map(run => {
|
|
201
217
|
const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : run.state;
|
|
219
|
+
return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
|
|
220
|
+
});
|
|
221
|
+
// Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
|
|
222
|
+
const timeWidth = Math.max(0, ...rows.map(r => r.time.length));
|
|
223
|
+
let statusWidth = Math.min(Math.max(0, ...rows.map(r => visibleWidth(r.status))), Math.floor(inner * 0.4));
|
|
224
|
+
if (inner - 2 - statusWidth - timeWidth - 4 < 12) statusWidth = 0;
|
|
225
|
+
const taskWidth = Math.max(1, inner - 2 - timeWidth - 2 - (statusWidth ? statusWidth + 2 : 0));
|
|
226
|
+
body = rows.map(({ run, task, status, time }) => {
|
|
227
|
+
const selected = run.id === this.selected;
|
|
228
|
+
const name = truncateToWidth(fit(task, taskWidth), taskWidth, '', true);
|
|
229
|
+
const meta = `${statusWidth ? `${truncateToWidth(fit(status, statusWidth), statusWidth, '', true)} ` : ''}${time.padStart(timeWidth)}`;
|
|
230
|
+
return selected ? color('accent', `→ ${name} ${meta}`) : ` ${name} ${color('muted', meta)}`;
|
|
205
231
|
});
|
|
206
|
-
if (!body.length) body.push('No agents yet. Ask the main agent to delegate a task.');
|
|
207
|
-
|
|
232
|
+
if (!body.length) body.push(color('muted', 'No agents yet. Ask the main agent to delegate a task.'));
|
|
233
|
+
info = `${list.filter(isActiveRun).length} active · ${list.length} saved${list.length ? ` · ${cursor + 1}/${list.length}` : ''}`;
|
|
208
234
|
hint = '↑↓ select · Enter inspect · m model · Tab usage · Esc close';
|
|
209
235
|
}
|
|
210
|
-
if (this.composer) { this.composer.focused = this.focused; body.push(...this.composer.render(
|
|
211
|
-
|
|
236
|
+
if (this.composer) { this.composer.focused = this.focused; body.push(...this.composer.render(inner)); hint = 'Enter sends guidance · Esc cancels message'; }
|
|
237
|
+
title = fit(title, Math.max(1, inner - visibleWidth(info) - 2));
|
|
238
|
+
const footer = wrapTextWithAnsi(this.error || (this.busy ? 'Updating agent…' : hint), inner).map(line => color(this.error ? 'error' : 'dim', line));
|
|
239
|
+
this.hintLines = footer.length;
|
|
240
|
+
const rule = color('border', '─'.repeat(Math.max(1, width)));
|
|
241
|
+
// Same layout as Pi's own selectors: rules above and below, content indented by one column.
|
|
242
|
+
const content = [spread(color('accent', title), color('dim', info), inner), '', ...body, '', ...footer].map(line => ` ${truncateToWidth(line, inner, '…')}`);
|
|
243
|
+
return [rule, ...content, rule].map(line => truncateToWidth(line, width));
|
|
212
244
|
}
|
|
213
245
|
}
|
|
214
246
|
|
|
247
|
+
let showing = false;
|
|
248
|
+
/** True while the panel is on screen; cleared before Pi restores the editor so the agent bar returns in the same frame. */
|
|
249
|
+
export const inspectorShowing = () => showing;
|
|
250
|
+
/** Takes the editor's place, like Pi's own selectors; Pi restores the editor and its draft on close. */
|
|
215
251
|
export function showInspector(ctx: ExtensionContext, options: Omit<Options, 'rows' | 'redraw' | 'done' | 'color' | 'context'>) {
|
|
216
|
-
return ctx.ui.custom<'model' | undefined>((tui, theme, _keys, done) =>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
return ctx.ui.custom<'model' | undefined>((tui, theme, _keys, done) => {
|
|
253
|
+
showing = true;
|
|
254
|
+
return new Inspector({ ...options,
|
|
255
|
+
rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: action => { showing = false; done(action); },
|
|
256
|
+
color: (tone, text) => theme.fg(tone, text), context: () => ctx.getContextUsage()?.tokens,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
220
259
|
}
|
package/src/memory.ts
CHANGED
|
@@ -6,7 +6,7 @@ export const NODE = 512;
|
|
|
6
6
|
export const VIEW = 128_000;
|
|
7
7
|
export const CAP = 30_000;
|
|
8
8
|
export type Kind = 'user' | 'talk' | 'tool' | 'echo' | 'note';
|
|
9
|
-
export interface Origin { source: 'claude' | 'codex' | 'chatgpt'; conversation: string; message: string; title: string; project?: string }
|
|
9
|
+
export interface Origin { source: 'claude' | 'claude-memory' | 'codex' | 'chatgpt'; conversation: string; message: string; title: string; project?: string }
|
|
10
10
|
export interface Entry { i: number; kind: Kind; text: string; size: number; date: string; receipt?: string; origin?: Origin }
|
|
11
11
|
export interface Part { l: number; i: number }
|
|
12
12
|
export interface Summary extends Part { text: string; size: number }
|
package/src/navigation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { matchesKey, truncateToWidth, type KeyId } from '@earendil-works/pi-tui';
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth, type KeyId } from '@earendil-works/pi-tui';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
|
-
import type
|
|
4
|
+
import { inspectorShowing, type InspectorPage } from './inspector.ts';
|
|
5
5
|
import { isActiveRun } from './runs.ts';
|
|
6
6
|
|
|
7
7
|
/** Configured before extension registration; this also works in legacy terminals with F6. */
|
|
@@ -50,10 +50,13 @@ export function mountNavigation(ctx: ExtensionContext, children: Children, short
|
|
|
50
50
|
return {
|
|
51
51
|
invalidate() {},
|
|
52
52
|
render(width: number) {
|
|
53
|
+
if (inspectorShowing()) return []; // The open panel replaces the editor and this bar.
|
|
53
54
|
const list = children.history.list(), running = list.filter(isActiveRun).length;
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const label = (page: InspectorPage, text: string) => navigation.selected === page ? theme.fg('accent', `› ${text}`) : theme.fg('muted', text);
|
|
56
|
+
const left = `${label('agents', `Agents: ${running} running · ${list.length - running} saved`)} ${label('usage', 'Usage')}`;
|
|
57
|
+
const hint = theme.fg('dim', navigation.selected ? '←→ select · Enter open · Esc input' : `${previous ? '' : '↓ select · '}${shortcut} inspect`);
|
|
58
|
+
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
59
|
+
return [truncateToWidth(gap >= 3 ? `${left}${' '.repeat(gap)}${hint}` : `${left} ${hint}`, width)];
|
|
57
60
|
},
|
|
58
61
|
};
|
|
59
62
|
}, { placement: 'belowEditor' });
|