pi-optchat 0.11.2 → 0.11.3
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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +17 -5
- package/src/profiles.ts +1 -1
- package/src/transcript.ts +16 -9
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windo
|
|
|
79
79
|
| Subagents | Anthropic Opus 5.5, high | `/optchat agents model` |
|
|
80
80
|
| Compactor (summaries, imports, fallback handoffs) | Anthropic Sonnet 5.5, medium | `/optchat model` |
|
|
81
81
|
|
|
82
|
-
All three are saved per profile. The main agent's model and thinking level are saved whenever you change them in Pi
|
|
82
|
+
All three are saved per profile. The main agent's model and thinking level are saved when you first open the profile and whenever you change them in Pi, then picked again when the profile opens, unless the saved model is no longer available. This includes resumed sessions: the profile's pick replaces the one the session last used. On Pi's command line, `--model` (or `--provider`) keeps that model, `--thinking` that level, and `--models` keeps a saved model only if it is in that list. Subagent and compactor settings do not follow the main model. If you use other providers, change them before chatting. Authentication uses Pi's existing provider login.
|
|
83
83
|
|
|
84
84
|
Compression and subagents make extra model requests with your provider credentials.
|
|
85
85
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ async function printReply(text: string) {
|
|
|
47
47
|
catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
50
|
+
interface Active { name: string; session: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
51
51
|
|
|
52
52
|
/** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
|
|
53
53
|
export const version = ((): string | undefined => {
|
|
@@ -73,6 +73,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
73
73
|
let view: string | undefined;
|
|
74
74
|
let prompt = '';
|
|
75
75
|
let runStarted = false;
|
|
76
|
+
/** The run was started by a custom message (`sendMessage` with `triggerTurn` while idle): its first message is that input, shown or hidden. */
|
|
77
|
+
let wake = false;
|
|
76
78
|
let fault: string | undefined;
|
|
77
79
|
/** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
|
|
78
80
|
* `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
|
|
@@ -133,7 +135,12 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
133
135
|
pi.on('thinking_level_select', (_event, ctx) => rememberMain(ctx));
|
|
134
136
|
const restoreMain = async (ctx: ExtensionContext, { name, config: { main } }: Active) => {
|
|
135
137
|
restored = undefined;
|
|
136
|
-
if (!main
|
|
138
|
+
if (!main) {
|
|
139
|
+
// Nothing saved yet: record where Pi is, so later opens keep this model instead of following Pi's default.
|
|
140
|
+
if (!pinnedModel) rememberMain(ctx);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
|
|
137
144
|
const model = ctx.modelRegistry.find(main.provider, main.model), thinking = launch.thinking ?? main.thinking;
|
|
138
145
|
restoring = true;
|
|
139
146
|
try {
|
|
@@ -223,6 +230,10 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
223
230
|
const openProfile = async (name: string, ctx: ExtensionContext) => {
|
|
224
231
|
const dir = profilePath(name);
|
|
225
232
|
if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
|
|
233
|
+
// Pi's RPC mode binds extensions twice after new_session/switch_session/fork/clone (setRebindSession, then rebindSession()),
|
|
234
|
+
// so session_start can arrive again for the session this profile already serves. Taking the lock again would meet our own socket.
|
|
235
|
+
// Only the same session is kept: another session without a session_shutdown still fails loudly below.
|
|
236
|
+
if (active?.name === name && active.session === ctx.sessionManager.getSessionId()) { await restoreMain(ctx, active); status(ctx); return; }
|
|
226
237
|
const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
|
|
227
238
|
let openingMemory: Memory | undefined;
|
|
228
239
|
try {
|
|
@@ -250,7 +261,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
250
261
|
.filter(r => !loggedReports.has(reportReceipt(r.text)));
|
|
251
262
|
atomicWrite(pending, JSON.stringify(reports));
|
|
252
263
|
rememberProfile(name);
|
|
253
|
-
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
|
|
264
|
+
active = { name, session: sessionId, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
|
|
254
265
|
await restoreMain(ctx, active);
|
|
255
266
|
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
|
|
256
267
|
// A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it.
|
|
@@ -406,7 +417,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
406
417
|
pi.on('before_agent_start', (event, ctx) => {
|
|
407
418
|
if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
|
|
408
419
|
const a = active;
|
|
409
|
-
flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
|
|
420
|
+
flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true; wake = event.messageType === 'custom';
|
|
410
421
|
previous = a.config.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), a.config.previousExchangeKB * 1000) : [];
|
|
411
422
|
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
412
423
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
@@ -420,7 +431,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
420
431
|
pi.on('message_end', async (event, ctx) => {
|
|
421
432
|
if (!active || !runStarted) return;
|
|
422
433
|
const bounded = boundedMessage(event.message);
|
|
423
|
-
|
|
434
|
+
// Pi puts the message that starts a run first, before any context it or other extensions inject.
|
|
435
|
+
const message = asUser(bounded, wake); if (bounded.role !== 'system') wake = false;
|
|
424
436
|
// Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
|
|
425
437
|
if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
|
|
426
438
|
try { await saveImages(active.memory.store, message.content); }
|
package/src/profiles.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const isWindows = process.platform === 'win32';
|
|
|
13
13
|
const PIPE_PREFIX = '\\\\.\\pipe\\';
|
|
14
14
|
|
|
15
15
|
export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
|
|
16
|
-
/** `main` is the last model and thinking level picked in Pi while this profile was open
|
|
16
|
+
/** `main` is the last model and thinking level picked in Pi while this profile was open, recorded on the first open too. */
|
|
17
17
|
export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice; main?: ModelChoice }
|
|
18
18
|
export const defaults: ProfileConfig = {
|
|
19
19
|
compactor: { provider: 'anthropic', model: 'claude-sonnet-5-5', thinking: 'medium' },
|
package/src/transcript.ts
CHANGED
|
@@ -24,13 +24,13 @@ export function typedText(content: unknown) {
|
|
|
24
24
|
const text = textContent(content, false);
|
|
25
25
|
return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
|
|
26
26
|
}
|
|
27
|
-
/** Pi's convertToLlm sends every custom message to the model as a user message.
|
|
28
|
-
* becomes a user message here too; another extension's starts with
|
|
29
|
-
* the compactor never takes it for the user's words.
|
|
30
|
-
* each turn, stays custom: the model still sees it, and memory and
|
|
31
|
-
* previous-exchange replay as user messages; memory logs them as `work`. */
|
|
32
|
-
export function asUser(message: AgentMessage): AgentMessage {
|
|
33
|
-
if (message.role !== 'custom' || !message.display) return message;
|
|
27
|
+
/** Pi's convertToLlm sends every custom message to the model as a user message. One that is shown, or that starts the run
|
|
28
|
+
* (`input`, shown or hidden), is part of the chat, so it becomes a user message here too; another extension's starts with
|
|
29
|
+
* "[customType] ", as the recipe marks background work, so the compactor never takes it for the user's words. Any other hidden
|
|
30
|
+
* one (display false), such as context an extension injects each turn, stays custom: the model still sees it, and memory and
|
|
31
|
+
* the replay leave it out. Reports reach the model and the previous-exchange replay as user messages; memory logs them as `work`. */
|
|
32
|
+
export function asUser(message: AgentMessage, input = false): AgentMessage {
|
|
33
|
+
if (message.role !== 'custom' || !message.display && !input) return message;
|
|
34
34
|
const { content, customType, timestamp } = message;
|
|
35
35
|
if (customType === REPORT_TYPE) return { role: 'user', content, timestamp };
|
|
36
36
|
const tag = `[${customType}] `;
|
|
@@ -57,7 +57,14 @@ export function previousExchange(branch: readonly SessionEntry[], limit = PREVIO
|
|
|
57
57
|
function latestExchange(branch: readonly SessionEntry[]) {
|
|
58
58
|
let end = -1;
|
|
59
59
|
let legacyEnd = branch.length;
|
|
60
|
-
|
|
60
|
+
// A run's first message is its input, a hidden custom one too: Pi puts it before any context it or other extensions inject.
|
|
61
|
+
const messages = (entries: readonly SessionEntry[], run = false) => entries.flatMap(entry => {
|
|
62
|
+
const message: AgentMessage | undefined = entry.type === 'message' ? entry.message : entry.type === 'custom_message'
|
|
63
|
+
? { role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) } : undefined;
|
|
64
|
+
if (!message) return [];
|
|
65
|
+
const input = run; if (message.role !== 'system') run = false;
|
|
66
|
+
return [asUser(message, input)];
|
|
67
|
+
});
|
|
61
68
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
62
69
|
const entry = branch[i];
|
|
63
70
|
if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
|
|
@@ -65,7 +72,7 @@ function latestExchange(branch: readonly SessionEntry[]) {
|
|
|
65
72
|
if (entry.data.state === 'end') end = i;
|
|
66
73
|
else if (entry.data.state === 'start') {
|
|
67
74
|
if (end >= 0) {
|
|
68
|
-
const exchange = completedExchange(messages(branch.slice(i + 1, end)), imageRef);
|
|
75
|
+
const exchange = completedExchange(messages(branch.slice(i + 1, end), true), imageRef);
|
|
69
76
|
if (exchange.length) return exchange;
|
|
70
77
|
}
|
|
71
78
|
end = -1;
|