pi-optchat 0.8.1 → 0.8.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 +6 -2
- package/package.json +1 -1
- package/src/agents.ts +29 -10
- package/src/browser.ts +1 -1
- package/src/cache.ts +16 -1
- package/src/checkpoint.ts +1 -1
- package/src/compactor.ts +5 -1
- package/src/images.ts +9 -19
- package/src/import/job.ts +2 -2
- package/src/inbox.ts +1 -1
- package/src/index.ts +24 -11
- package/src/memory.ts +51 -69
- package/src/menu.ts +61 -0
- package/src/profiles.ts +16 -1
- package/src/prompts.ts +4 -1
- package/src/report-message.ts +2 -1
- package/src/runs.ts +1 -1
- package/src/settings-page.ts +3 -3
- package/src/store.ts +129 -0
- package/src/tools.ts +30 -9
- package/src/transcript.ts +44 -5
- package/src/usage.ts +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,8 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
100
100
|
- Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
101
101
|
- Each child reports to its parent as soon as it finishes, as one message starting `[id]`. A stopped or failed child reports its stop or error text. Turn **Group subagent reports** on to have the children of one spawn report together: when the last of them finishes, their reports reach the parent as one message, `[id] report` each, in spawn order. Each finished report is journaled right away, so if Pi dies before the rest finish, the reports it already has are delivered at the next start. Messages sent with `tell_parent`, connected windows and their handoffs are never held back, and a child resumed with `tell` reports on its own. The parent stays alive to receive reports; it never polls.
|
|
102
102
|
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages; memory logs them as kind `work` (older logs hold them as `user` messages starting `[id]`). (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
103
|
+
- Every report ends with `Full chat: zoom("<id>")`, so the agent can check it against what the child actually did: `zoom` with a run ID returns that child's chat so far as `kind|text` lines (its task without the memory view, replies, tool calls, tool results cut to their first and last ~500 characters, and what it was told), in 25,000-character pages that say which `offset` to go on from. It works on running children too; a subagent can read only its own descendants' chats, as with `tell`. The report box leaves the trailer out.
|
|
104
|
+
- While any subagent runs, each new input to the main agent (your message, a report, a `tell_parent` message, the restart note, a connected window's message) ends with one status line, `[OptChat status] Agents at work now: 8f28e694 "Review PR #123 carefully", …` (id and the first words of each task), so it knows what is still out without asking. It is rebuilt each time and never stored. It isn't repeated on the calls after a tool result within one turn, and nothing is sent while no agent runs. The main prompt says what the label means, so the model doesn't take it for the user. It comes after the cached view and turn, with the turn's cache mark on the block before it, so it costs no cache.
|
|
103
105
|
- The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
|
|
104
106
|
- `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one active slot. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
|
|
105
107
|
- By default only the main agent starts subagents. Set **Subagent levels** in `/optchat settings` to let them delegate further (3 means child, grandchild, great-grandchild).
|
|
@@ -121,7 +123,7 @@ An **Agents | Usage | Activity** bar sits below the input.
|
|
|
121
123
|
|
|
122
124
|
Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
|
|
123
125
|
|
|
124
|
-
**Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model. Press **D** twice to delete the selected agent and the agents it started, with their transcripts. An agent that is still working is stopped first, and its stop report reaches its parent before it is deleted. The report tells the parent that you dropped the task, so it does not delegate it again. The main agent can no longer resume a deleted agent. Deleting a connected window that is still open stops it like closing the window; its handoff still reaches the main agent, but the transcript the handoff points to is deleted.
|
|
126
|
+
**Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model. Press **D** twice to delete the selected agent and the agents it started, with their transcripts. An agent that is still working is stopped first, and its stop report reaches its parent before it is deleted. The report tells the parent that you dropped the task, so it does not delegate it again. Deleting a finished agent sends no report, so memory logs `[id] deleted by the user` right away, without a model turn. The main agent can no longer resume a deleted agent. Deleting a connected window that is still open stops it like closing the window; its handoff still reaches the main agent, but the transcript the handoff points to is deleted.
|
|
125
127
|
|
|
126
128
|
**Enter** swaps the screen to that agent's conversation, drawn with Pi's own chat components, so it reads like the main chat: its task, replies, and collapsed tool calls, following live output. Typing and **Enter** send it guidance. Guidance from the main agent and reports from the agent's own agents show in labelled boxes, so only your own messages look typed. **Escape** goes back to the main chat.
|
|
127
129
|
|
|
@@ -211,6 +213,8 @@ Each profile folder is a local Git repository, committed after each turn and on
|
|
|
211
213
|
|
|
212
214
|
To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
|
|
213
215
|
|
|
216
|
+
Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files. `FileStore` (the layout above) is the only store today; another, such as Postgres, would implement the same few operations, and its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock. Import records (`imports/*.json`, the staging generations and the `active-memory.json` pointer) are per machine, so a second machine with the same `~/.claude` or Codex history on disk may offer those sessions for import again.
|
|
217
|
+
|
|
214
218
|
## Windows
|
|
215
219
|
|
|
216
220
|
The same install and commands work on Windows, and the offline tests run there too. What differs:
|
|
@@ -234,7 +238,7 @@ The same install and commands work on Windows, and the offline tests run there t
|
|
|
234
238
|
|
|
235
239
|
## How it differs from the recipe
|
|
236
240
|
|
|
237
|
-
`src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes; tool descriptions stay with their tools. The recipe's prompts are lightly adapted. The main agent's turn rules are the revised recipe's (2026-10-08) word for word, without its paragraph on computers, with reports starting "[id] " instead of "[Name]", and with
|
|
241
|
+
`src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes; tool descriptions stay with their tools. The recipe's prompts are lightly adapted. The main agent's turn rules are the revised recipe's (2026-10-08) word for word, without its paragraph on computers, with reports starting "[id] " instead of "[Name]", and with two added sentences: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent, and a message starting "[OptChat status]" is OptChat's own note, not the user; the view doc keeps the original's description of the view, drops its "No message appears in full" line (untrue with Previous exchange on) and its list of when to zoom, adds one sentence on zooming out from a message to the summaries above it, and ends with the revised recipe's rule that the view is the truth and zoom the only way through it; the compactor prompt is the revised recipe's intro, view and Compactions sections, with OptChat's kinds (`talk` for replies, subagent reports as `work`) and without its turn-only sections, and with its value list reworded so the size limit is a ceiling rather than a target (bare approvals are recorded as the decision they made, lines don't narrate what wasn't seen yet, and a tool result is described by what its output shows rather than by a cause guessed from an exit code or an empty match; an A/B found less filler and no recall loss, see #129); unlike the recipe, the compactor doesn't get your instructions, since an A/B found they added nothing (#112); and the subagent prompt is the original's plus a closing note on time: report progress on long tasks with tell_parent, and say so before waiting on something slow, so the main agent can go on. OptChat keeps the recipe's numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000 (so between batches it only grows at its end and each turn reads the last one's view from the cache; the view is saved and reloaded as it was), binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Compactions get their own view, as in the 2026-10-08 revision of the recipe: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built. A message's summary starts once fewer than 8 lines before it are unsummarized, so up to 8 compactions run at once on one cached view. An import logs its messages the same way, waiting only while 8 messages and due merges are unbuilt, so the merges keep up. Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it: cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (see #101). The compactor's task and its "Too long" retry are the recipe's, verbatim, with a 512-dash ruler for the size. Anthropic requests split the view into blocks of 4 lines with cache marks on the last whole one and on the blocks 20 and 40 before it, so a turn that adds up to 240 lines of tool calls and results still reads the last view from the cache, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
|
|
238
242
|
|
|
239
243
|
Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions (the ones that change the recipe's behaviour are settings, see [Settings](#settings)):
|
|
240
244
|
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { memoryTools, searchTool } from './tools.ts';
|
|
|
11
11
|
import { type Memory } from './memory.ts';
|
|
12
12
|
import type { ModelChoice } from './compactor.ts';
|
|
13
13
|
import { cachePayload } from './cache.ts';
|
|
14
|
-
import { CUT_OFF, RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
14
|
+
import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
15
15
|
import { UsageLedger } from './usage.ts';
|
|
16
|
-
import { textContent } from './transcript.ts';
|
|
16
|
+
import { fullChat, textContent } from './transcript.ts';
|
|
17
17
|
import { Type } from 'typebox';
|
|
18
18
|
import { result } from './tools.ts';
|
|
19
19
|
import type { HandoffEvidence } from './handoff.ts';
|
|
@@ -107,6 +107,13 @@ export class Children {
|
|
|
107
107
|
const file = this.history.records.get(id)?.sessionFile;
|
|
108
108
|
return file ? sessionMessages(file) : [];
|
|
109
109
|
}
|
|
110
|
+
/** A run's messages for zoom, live or finished; undefined for no such run. A subagent `reader` sees only its own descendants, as with tell. */
|
|
111
|
+
transcript(id: string, reader?: string) {
|
|
112
|
+
const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
|
|
113
|
+
return known ? this.messages(id) : undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Runs at work now: running, waiting for children, or stopping; not paused. */
|
|
116
|
+
get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); }
|
|
110
117
|
subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
111
118
|
private changed() { for (const listener of this.listeners) listener(); }
|
|
112
119
|
private save(info: RunInfo) { this.history.save(info); this.changed(); }
|
|
@@ -235,7 +242,7 @@ export class Children {
|
|
|
235
242
|
await loader.reload();
|
|
236
243
|
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
237
244
|
model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
|
|
238
|
-
customTools: [...memoryTools(() => this.memory), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
|
|
245
|
+
customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
|
|
239
246
|
excludeTools: delegates ? [] : ['spawn', 'tell'],
|
|
240
247
|
});
|
|
241
248
|
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
@@ -307,7 +314,10 @@ export class Children {
|
|
|
307
314
|
// Nothing to hand over: the run waits for its next message instead of ending, and whoever waits on its report hears why.
|
|
308
315
|
if (transition(info, 'paused')) {
|
|
309
316
|
this.save(info);
|
|
310
|
-
|
|
317
|
+
// In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
|
|
318
|
+
const note = `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
|
|
319
|
+
live.batch = undefined;
|
|
320
|
+
if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
|
|
311
321
|
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
312
322
|
}
|
|
313
323
|
continue;
|
|
@@ -324,7 +334,8 @@ export class Children {
|
|
|
324
334
|
continue;
|
|
325
335
|
}
|
|
326
336
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
327
|
-
|
|
337
|
+
// An error ends the turn, not the run: children still at work report to it and it answers them, as the main agent does.
|
|
338
|
+
if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted') && !live.pendingReports.length && !this.directChildren(info.id).length) break;
|
|
328
339
|
if (live.pendingGuidance.length) {
|
|
329
340
|
transition(info, 'running'); this.save(info);
|
|
330
341
|
await session.prompt(live.pendingGuidance.shift()!);
|
|
@@ -376,8 +387,11 @@ export class Children {
|
|
|
376
387
|
if (this.deleting.has(info.id)) info.report = 'Deleted by the user while it was working. The user dropped this task on purpose: do not redo it or delegate it again unless they ask.';
|
|
377
388
|
// A metadata failure must not suppress delivery of the actual result.
|
|
378
389
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
379
|
-
|
|
380
|
-
|
|
390
|
+
// A deleted run's transcript is gone.
|
|
391
|
+
await this.deliver(info, live.batch, `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
|
|
392
|
+
}
|
|
393
|
+
/** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
|
|
394
|
+
private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
|
|
381
395
|
let count: number | undefined;
|
|
382
396
|
if (batch) {
|
|
383
397
|
batch.reports.set(info.id, text);
|
|
@@ -387,7 +401,7 @@ export class Children {
|
|
|
387
401
|
}
|
|
388
402
|
if (info.parentId) {
|
|
389
403
|
const parent = this.running.get(info.parentId);
|
|
390
|
-
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
|
|
404
|
+
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); parent.wake?.(); this.changed(); }
|
|
391
405
|
return;
|
|
392
406
|
}
|
|
393
407
|
if (this.closing) this.memory.append('work', text);
|
|
@@ -540,7 +554,8 @@ export class Children {
|
|
|
540
554
|
}
|
|
541
555
|
if (orphans.length) lines.push(`Also cut off, but not resumed because their parent agent is not running: ${orphans.join(', ')}.`);
|
|
542
556
|
if (!resumed.length && !lines.length) return;
|
|
543
|
-
await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off;
|
|
557
|
+
await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off; ${this.settings.groupReports
|
|
558
|
+
? 'grouped reports do not survive a restart, so each one reports on its own.' : 'reports arrive as usual.'}` : ''}`, ...lines].join('\n'));
|
|
544
559
|
}
|
|
545
560
|
async finish(id: string, reason: FinishReason) {
|
|
546
561
|
const live = this.running.get(id);
|
|
@@ -583,7 +598,7 @@ export class Children {
|
|
|
583
598
|
: `No transcript available. Run metadata: ${join(this.profileDirectory, 'runs', `${record.id}.optchat.json`)}`;
|
|
584
599
|
return `[${record.id}] ${record.state}${record.parentId ? ` · parent ${record.parentId}` : ''}\n${location}${transcriptError ? `\nTranscript read failed: ${transcriptError}` : ''}`;
|
|
585
600
|
}).join('\n');
|
|
586
|
-
handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}`;
|
|
601
|
+
handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}\n\n${fullChat(run.id)}`;
|
|
587
602
|
run.report = handoff.text; this.save(run);
|
|
588
603
|
}
|
|
589
604
|
await this.report(handoff.text, { once: true });
|
|
@@ -607,6 +622,8 @@ export class Children {
|
|
|
607
622
|
async remove(id: string) {
|
|
608
623
|
if (!this.history.records.has(id)) throw new Error(`No subagent ${id}.`);
|
|
609
624
|
if (this.deleting.has(id)) throw new Error(`${id} is already being deleted.`);
|
|
625
|
+
// A run that is live while being deleted reports its own deletion, even one a tell was resuming; a finished one has nothing left to say, so memory is told here.
|
|
626
|
+
let reports = false;
|
|
610
627
|
const runs = () => [id, ...this.history.descendants(id).map(run => run.id)], marked = new Set<string>();
|
|
611
628
|
// An overlapping delete of a descendant keeps its own marks, so neither delete clears the other's.
|
|
612
629
|
const mark = () => { for (const run of runs()) if (!this.deleting.has(run)) { marked.add(run); this.deleting.add(run); } };
|
|
@@ -617,6 +634,7 @@ export class Children {
|
|
|
617
634
|
await Promise.allSettled(this.launches);
|
|
618
635
|
const live = runs().flatMap(run => this.running.get(run) ?? []);
|
|
619
636
|
if (!live.length) break;
|
|
637
|
+
if (live.some(run => run.info.id === id)) reports = true;
|
|
620
638
|
mark();
|
|
621
639
|
await Promise.allSettled(live.map(run => this.stop(run.info.id)));
|
|
622
640
|
await Promise.allSettled(live.map(run => run.completion));
|
|
@@ -625,6 +643,7 @@ export class Children {
|
|
|
625
643
|
const records = runs().flatMap(run => this.history.records.get(run) ?? []);
|
|
626
644
|
if (records.some(run => run.connected && !run.handoff?.delivered)) throw new Error(`${id} has a connected conversation whose handoff has not reached the main agent yet, so it was kept.`);
|
|
627
645
|
for (const run of records.reverse()) this.history.remove(run.id); // Children first, so a failure never leaves one without its parent.
|
|
646
|
+
if (!reports) this.memory.append('work', `[${id}] deleted by the user`);
|
|
628
647
|
} finally { for (const run of marked) this.deleting.delete(run); this.changed(); }
|
|
629
648
|
}
|
|
630
649
|
async close() {
|
package/src/browser.ts
CHANGED
package/src/cache.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isView } from './memory.ts';
|
|
2
|
+
import { AT_WORK } from './prompts.ts';
|
|
2
3
|
|
|
3
4
|
export function record(value: unknown): value is Record<string, unknown> {
|
|
4
5
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -20,6 +21,15 @@ export function splitView(text: string) {
|
|
|
20
21
|
* 60 blocks (240 lines). With the end of the request, that is Anthropic's limit of 4 marks. */
|
|
21
22
|
const MARKS = [0, 20, 40];
|
|
22
23
|
|
|
24
|
+
/** The status line's own message, in Pi's context and in Anthropic's payload alike. */
|
|
25
|
+
export const isAtWork = (message: unknown) => record(message) && message.role === 'user' && Array.isArray(message.content) && message.content.length === 1
|
|
26
|
+
&& record(message.content[0]) && typeof message.content[0].text === 'string' && message.content[0].text.startsWith(AT_WORK);
|
|
27
|
+
function lastBlock(message: Record<string, unknown>) {
|
|
28
|
+
if (typeof message.content === 'string') message.content = [{ type: 'text', text: message.content }];
|
|
29
|
+
const block: unknown = Array.isArray(message.content) ? message.content.at(-1) : undefined;
|
|
30
|
+
return record(block) ? block : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Anthropic: marks on the view (see MARKS) plus automatic end-of-request caching, so the next call finds the last one's entry
|
|
24
34
|
* and pays only for the lines after it. */
|
|
25
35
|
export function cachePayload(payload: unknown): unknown {
|
|
@@ -46,6 +56,11 @@ export function cachePayload(payload: unknown): unknown {
|
|
|
46
56
|
delete message.cache_control;
|
|
47
57
|
if (Array.isArray(message.content)) for (const item of message.content) if (record(item) && !view.has(item)) delete item.cache_control;
|
|
48
58
|
}
|
|
49
|
-
|
|
59
|
+
// The status line changes between calls, so the turn's mark goes on the block before it rather than at the end of the request.
|
|
60
|
+
// Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
|
|
61
|
+
const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
|
|
62
|
+
const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
|
|
63
|
+
if (turn) turn.cache_control = { type: 'ephemeral' };
|
|
64
|
+
else payload.cache_control = { type: 'ephemeral' };
|
|
50
65
|
return payload;
|
|
51
66
|
}
|
package/src/checkpoint.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { atomicWrite } from './
|
|
5
|
+
import { atomicWrite } from './store.ts';
|
|
6
6
|
const exec = promisify(execFile);
|
|
7
7
|
|
|
8
8
|
export async function checkpoint(directory: string) {
|
package/src/compactor.ts
CHANGED
|
@@ -51,7 +51,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
51
51
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
52
52
|
const thinking = reasoningFor(model, selected.thinking);
|
|
53
53
|
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
|
|
54
|
-
|
|
54
|
+
// OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
|
|
55
|
+
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
|
|
55
56
|
const view = splitView(input.context);
|
|
56
57
|
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
57
58
|
const tries: string[] = [];
|
|
@@ -71,6 +72,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
71
72
|
} finally { warmed(false); }
|
|
72
73
|
onUsage(reply);
|
|
73
74
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
75
|
+
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
76
|
+
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
74
77
|
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
75
78
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
76
79
|
if (!line) throw new Error('Compactor returned no text.');
|
|
@@ -81,6 +84,7 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
81
84
|
const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
|
|
82
85
|
messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
|
|
83
86
|
}
|
|
87
|
+
if (!tries.length) throw new Error('Compactor returned no whole line in 5 tries.');
|
|
84
88
|
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
85
89
|
};
|
|
86
90
|
}
|
package/src/images.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
2
|
import type { ImageContent } from '@earendil-works/pi-ai';
|
|
5
3
|
import { resizeImage } from '@earendil-works/pi-coding-agent';
|
|
6
|
-
import {
|
|
4
|
+
import { isMime, type Store } from './store.ts';
|
|
7
5
|
|
|
8
6
|
/** Kept images fit 2048 px on the long side and ~1.5 MB (2 MB as base64), re-encoded as JPEG 85 only when they don't already. */
|
|
9
7
|
export const IMAGE_LIMITS = { maxWidth: 2048, maxHeight: 2048, maxBytes: 2 * 1024 * 1024, jpegQuality: 85 };
|
|
10
|
-
const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } as const;
|
|
11
|
-
type Mime = keyof typeof EXTENSIONS;
|
|
12
|
-
const isMime = (mime: string): mime is Mime => Object.hasOwn(EXTENSIONS, mime);
|
|
13
|
-
const MIMES = new Map<string, Mime>((Object.keys(EXTENSIONS) as Mime[]).map(mime => [EXTENSIONS[mime], mime]));
|
|
14
8
|
|
|
15
9
|
const hash = (image: ImageContent) => createHash('sha256').update(image.data).digest('hex').slice(0, 16);
|
|
16
10
|
/** How a message's image reads in memory: named by the image as the message holds it, before any shrinking, so it always gets the same name. */
|
|
@@ -19,26 +13,22 @@ const REF = /\[image ([0-9a-f]{16})\]/g;
|
|
|
19
13
|
|
|
20
14
|
export const isImage = (part: unknown): part is ImageContent => typeof part === 'object' && part !== null
|
|
21
15
|
&& 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
|
|
22
|
-
const stored = (directory: string, name: string) => {
|
|
23
|
-
try { return readdirSync(join(directory, 'images')).find(file => file.startsWith(`${name}.`) && MIMES.has(file.slice(name.length + 1))); } catch { return undefined; }
|
|
24
|
-
};
|
|
25
16
|
|
|
26
|
-
/** Keeps each image of a message's content
|
|
27
|
-
export async function saveImages(
|
|
17
|
+
/** Keeps each image of a message's content in the store, once per distinct image, shrunk to IMAGE_LIMITS. */
|
|
18
|
+
export async function saveImages(store: Store, content: unknown) {
|
|
28
19
|
if (!Array.isArray(content)) return;
|
|
29
20
|
for (const image of content.filter(isImage)) {
|
|
30
21
|
const name = hash(image);
|
|
31
|
-
if (
|
|
22
|
+
if (await store.image(name)) continue;
|
|
32
23
|
// Pi's own resizer (Photon, WASM), as its read tool uses: it returns small images untouched, and null if it can't decode.
|
|
33
24
|
const kept = await resizeImage(Buffer.from(image.data, 'base64'), image.mimeType, IMAGE_LIMITS) ?? image;
|
|
34
|
-
if (isMime(kept.mimeType))
|
|
25
|
+
if (isMime(kept.mimeType)) await store.putImage(name, kept.mimeType, Buffer.from(kept.data, 'base64'));
|
|
35
26
|
}
|
|
36
27
|
}
|
|
37
28
|
|
|
38
29
|
/** The kept images a text refers to, in order, each once; ones never kept are skipped. */
|
|
39
|
-
export function loadImages(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
30
|
+
export async function loadImages(store: Store, text: string): Promise<ImageContent[]> {
|
|
31
|
+
const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
|
|
32
|
+
return (await Promise.all(names.map(name => store.image(name))))
|
|
33
|
+
.flatMap(kept => kept ? [{ type: 'image' as const, data: Buffer.from(kept.data).toString('base64'), mimeType: kept.mimeType }] : []);
|
|
44
34
|
}
|
package/src/import/job.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { Memory, bytes, isEntry, type Entry, type Compressor } from '../memory.ts';
|
|
5
|
-
import { atomicWrite } from '../
|
|
5
|
+
import { atomicWrite } from '../store.ts';
|
|
6
6
|
import { record } from '../cache.ts';
|
|
7
7
|
import { copyKey, type ImportedEntry } from './sources.ts';
|
|
8
8
|
import { DEFAULT_SETTINGS } from '../settings.ts';
|
|
@@ -106,7 +106,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
|
|
|
106
106
|
const plan: unknown[] = readFileSync(join(path, STAGED), 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
107
107
|
// Every line must parse, and the plan must be whole and in order, so a damaged plan can't drop messages silently.
|
|
108
108
|
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.');
|
|
109
|
-
const memory =
|
|
109
|
+
const memory = await Memory.open(path, compress, () => {}, undefined, jobs);
|
|
110
110
|
const report = () => progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
|
|
111
111
|
const timer = setInterval(report, 500);
|
|
112
112
|
try {
|
package/src/inbox.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { record } from './cache.ts';
|
|
5
|
-
import { atomicWrite } from './
|
|
5
|
+
import { atomicWrite } from './store.ts';
|
|
6
6
|
import type { Memory } from './memory.ts';
|
|
7
7
|
|
|
8
8
|
interface Arrival { id: string; text: string; date: string }
|
package/src/index.ts
CHANGED
|
@@ -6,13 +6,14 @@ import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
|
6
6
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
7
7
|
import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
|
-
import {
|
|
9
|
+
import { Memory } from './memory.ts';
|
|
10
|
+
import { atomicWrite, storeFor } from './store.ts';
|
|
10
11
|
import { createCompressor } from './compactor.ts';
|
|
11
|
-
import { createProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig,
|
|
12
|
+
import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
12
13
|
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, VIEW_DOC } from './prompts.ts';
|
|
13
14
|
import { cachePayload, record } from './cache.ts';
|
|
14
15
|
import { saveImages } from './images.ts';
|
|
15
|
-
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
16
|
+
import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
16
17
|
import { registerReportRenderer, type ReportDetails } from './report-message.ts';
|
|
17
18
|
import { memoryTools, result, searchTool } from './tools.ts';
|
|
18
19
|
import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
|
|
@@ -30,6 +31,7 @@ import { openConnectedWindow, registerConnectedRenderer } from './connected-wind
|
|
|
30
31
|
import { createHandoffSummarizer } from './handoff.ts';
|
|
31
32
|
import { mainTitle, TabTitle } from './title.ts';
|
|
32
33
|
import { showModelPicker, showSettings } from './settings-page.ts';
|
|
34
|
+
import { showMenu } from './menu.ts';
|
|
33
35
|
|
|
34
36
|
const binding = 'optchat.profile';
|
|
35
37
|
const CONNECT_MODES = ['auto', 'join', 'off'] as const;
|
|
@@ -47,6 +49,15 @@ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileCo
|
|
|
47
49
|
allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
|
|
48
50
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
49
51
|
|
|
52
|
+
/** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
|
|
53
|
+
export const version = ((): string | undefined => {
|
|
54
|
+
try {
|
|
55
|
+
const data: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
56
|
+
return data && typeof data === 'object' && 'version' in data && typeof data.version === 'string' ? data.version : undefined;
|
|
57
|
+
} catch { return undefined; }
|
|
58
|
+
})();
|
|
59
|
+
const brand = version ? `OptChat v${version}` : 'OptChat';
|
|
60
|
+
|
|
50
61
|
export default function optchat(pi: ExtensionAPI) {
|
|
51
62
|
let active: Active | undefined;
|
|
52
63
|
let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
|
|
@@ -176,7 +187,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
176
187
|
const openProfile = async (name: string, ctx: ExtensionContext) => {
|
|
177
188
|
const dir = profilePath(name);
|
|
178
189
|
if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
|
|
179
|
-
const unlock = await
|
|
190
|
+
const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
|
|
180
191
|
let openingMemory: Memory | undefined;
|
|
181
192
|
try {
|
|
182
193
|
const config = loadConfig(dir);
|
|
@@ -186,7 +197,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
186
197
|
const pending = join(dir, 'pending-reports.json');
|
|
187
198
|
const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
|
|
188
199
|
if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
|
|
189
|
-
const memory =
|
|
200
|
+
const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
|
|
190
201
|
usage.compression(message, 'compactor', sessionId);
|
|
191
202
|
status(ctx);
|
|
192
203
|
}, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
|
|
@@ -208,7 +219,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
208
219
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
209
220
|
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
210
221
|
status(ctx);
|
|
211
|
-
ctx.ui.notify(
|
|
222
|
+
ctx.ui.notify(`${brand} · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
212
223
|
const queuedReports = [...reports];
|
|
213
224
|
if (!pendingImport(dir)) {
|
|
214
225
|
const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
|
|
@@ -328,7 +339,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
328
339
|
const message = asUser(bounded);
|
|
329
340
|
// Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
|
|
330
341
|
if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
|
|
331
|
-
try { await saveImages(active.memory.
|
|
342
|
+
try { await saveImages(active.memory.store, message.content); }
|
|
332
343
|
catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
|
|
333
344
|
}
|
|
334
345
|
if (!active) return;
|
|
@@ -379,7 +390,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
379
390
|
view = a.memory.render(); // Capture old history before logging the new input.
|
|
380
391
|
flush();
|
|
381
392
|
}
|
|
382
|
-
|
|
393
|
+
// Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached.
|
|
394
|
+
return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous, atWork(a.children.working)) };
|
|
383
395
|
} catch (error) {
|
|
384
396
|
// Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
|
|
385
397
|
ctx.abort();
|
|
@@ -415,7 +427,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
415
427
|
});
|
|
416
428
|
registerConnectedRenderer(pi);
|
|
417
429
|
registerReportRenderer(pi);
|
|
418
|
-
|
|
430
|
+
const [zoom, date] = memoryTools(() => required().memory, id => required().children.transcript(id));
|
|
431
|
+
pi.registerTool(zoom); pi.registerTool(date);
|
|
419
432
|
pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
|
|
420
433
|
// The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
|
|
421
434
|
// Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
|
|
@@ -471,8 +484,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
471
484
|
let action = args.trim();
|
|
472
485
|
if (!action) {
|
|
473
486
|
const a = active;
|
|
474
|
-
|
|
475
|
-
|
|
487
|
+
action = await showMenu(ctx, brand, a && { profile: a.name, messages: a.memory.root.length, pending: a.memory.pending,
|
|
488
|
+
compactor: a.config.compactor, agents: a.config.subagent, error: a.memory.lastError }) ?? '';
|
|
476
489
|
}
|
|
477
490
|
if (action === 'import') {
|
|
478
491
|
const a = required();
|
package/src/memory.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { FileStore, storeFor, type Saved, type Store } from './store.ts';
|
|
4
3
|
|
|
5
4
|
export const NODE = 512;
|
|
6
5
|
export const VIEW = 128_000;
|
|
@@ -34,20 +33,6 @@ export function cap(text: string, limit = CAP) {
|
|
|
34
33
|
const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
|
|
35
34
|
return head + notice(text.length - head.length - tail.length) + tail;
|
|
36
35
|
}
|
|
37
|
-
export function localDay(date = new Date()) {
|
|
38
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
39
|
-
}
|
|
40
|
-
export function atomicWrite(file: string, text: string | Uint8Array) {
|
|
41
|
-
mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
|
|
42
|
-
const temporary = `${file}.${process.pid}.tmp`;
|
|
43
|
-
// One read-write handle: Windows refuses fsync on a read-only one, and it cannot open a directory at all, so there the file flush is the whole guarantee.
|
|
44
|
-
const fd = openSync(temporary, 'w+', 0o600);
|
|
45
|
-
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
46
|
-
renameSync(temporary, file);
|
|
47
|
-
if (process.platform === 'win32') return;
|
|
48
|
-
const parent = openSync(resolve(file, '..'), 'r');
|
|
49
|
-
try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
50
|
-
}
|
|
51
36
|
/** The index of the view's most due pair of sibling lines whose parent is built, or -1 (recipe §3.2). A pair is as due as
|
|
52
37
|
* long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
|
|
53
38
|
export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
|
|
@@ -94,33 +79,6 @@ class Sawtooth {
|
|
|
94
79
|
return this.parts.length < lines;
|
|
95
80
|
}
|
|
96
81
|
}
|
|
97
|
-
const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
|
|
98
|
-
/** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
|
|
99
|
-
export function appendJson(file: string, value: unknown, size?: number) {
|
|
100
|
-
const fd = openSync(file, 'a+', 0o600);
|
|
101
|
-
try {
|
|
102
|
-
const length = fstatSync(fd).size, last = Buffer.alloc(1);
|
|
103
|
-
if (size !== undefined && length !== size) throw otherWriter(file);
|
|
104
|
-
const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
|
|
105
|
-
const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
|
|
106
|
-
if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
|
|
107
|
-
fsyncSync(fd);
|
|
108
|
-
return fstatSync(fd).size;
|
|
109
|
-
} finally { closeSync(fd); }
|
|
110
|
-
}
|
|
111
|
-
function records(dir: string, warn: (s: string) => void): unknown[] {
|
|
112
|
-
if (!existsSync(dir)) return [];
|
|
113
|
-
const result: unknown[] = [];
|
|
114
|
-
for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
|
|
115
|
-
const file = join(dir, name);
|
|
116
|
-
for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
|
|
117
|
-
if (!line.trim()) continue;
|
|
118
|
-
try { result.push(JSON.parse(line)); }
|
|
119
|
-
catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return result;
|
|
123
|
-
}
|
|
124
82
|
function object(value: unknown): value is Record<string, unknown> {
|
|
125
83
|
return typeof value === 'object' && value !== null;
|
|
126
84
|
}
|
|
@@ -146,7 +104,6 @@ export class Memory {
|
|
|
146
104
|
private readonly busy = new Map<number, Promise<void>>();
|
|
147
105
|
private readonly retryAt = new Map<number, number>();
|
|
148
106
|
private readonly reported = new Set<number>();
|
|
149
|
-
private readonly lastSeenBytes = new Map<string, number>();
|
|
150
107
|
/** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
|
|
151
108
|
private readonly unbuilt = new Set<number>();
|
|
152
109
|
private readonly merges = new Map<number, Part>();
|
|
@@ -157,21 +114,34 @@ export class Memory {
|
|
|
157
114
|
private stopped = false;
|
|
158
115
|
private readonly chat: Sawtooth;
|
|
159
116
|
private readonly compaction: Sawtooth;
|
|
117
|
+
/** The last store write still running, if any. Once an append fails, later writes are skipped and appends throw, as after a failed file write. */
|
|
118
|
+
private writing?: Promise<void>;
|
|
119
|
+
private failed?: string;
|
|
120
|
+
/** Where this memory's local files live: all of it for a FileStore, and the browser page, runs and imports for any store. */
|
|
121
|
+
readonly directory: string;
|
|
122
|
+
readonly store: Store;
|
|
160
123
|
lastError?: string;
|
|
161
124
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
125
|
+
/** Opens the memory in `directory` through the store `storeFor` picks, after its load. */
|
|
126
|
+
static async open(directory: string, compress: Compressor, warn: (s: string) => void = console.error, budget = VIEW, jobs = 8) {
|
|
127
|
+
const store = storeFor(directory, warn);
|
|
128
|
+
return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
|
|
129
|
+
}
|
|
130
|
+
/** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
|
|
131
|
+
* waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
|
|
132
|
+
constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
|
|
164
133
|
private readonly warn: (s: string) => void = console.error,
|
|
165
134
|
readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
|
|
166
135
|
this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
|
|
167
136
|
this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
137
|
+
let saved: Saved;
|
|
138
|
+
if (typeof source === 'string') { const store = new FileStore(source, warn); this.directory = source; this.store = store; saved = store.load(); }
|
|
139
|
+
else ({ directory: this.directory, store: this.store, saved } = source);
|
|
140
|
+
const log = saved.entries;
|
|
171
141
|
const entries = log.every(isEntry) ? log.sort((a, b) => a.i - b.i) : undefined;
|
|
172
142
|
if (!entries || entries.some((entry, i) => entry.i !== i)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
|
|
173
143
|
for (const value of entries) this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
|
|
174
|
-
for (const value of
|
|
144
|
+
for (const value of saved.tree) {
|
|
175
145
|
if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
|
|
176
146
|
throw new Error('Invalid OptChat summary record.');
|
|
177
147
|
this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
|
|
@@ -179,34 +149,43 @@ export class Memory {
|
|
|
179
149
|
for (const node of this.tree.values()) this.queueParent(node);
|
|
180
150
|
for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
|
|
181
151
|
// Refolding the log gives a different view than the live one, and every cache entry would miss, so the view is saved.
|
|
182
|
-
const loaded = this.load();
|
|
152
|
+
const loaded = this.load(saved.view);
|
|
183
153
|
for (const part of this.view) this.compaction.push(part);
|
|
184
154
|
this.compaction.fit(loaded, true);
|
|
185
155
|
for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
|
|
186
156
|
this.fit(); this.save(); this.schedule();
|
|
187
157
|
}
|
|
188
|
-
private checkLog(next: string) {
|
|
189
|
-
const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
|
|
190
|
-
if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
|
|
191
|
-
}
|
|
192
158
|
append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) {
|
|
193
159
|
if (this.stopped) throw new Error('Memory is closed.');
|
|
160
|
+
if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`);
|
|
194
161
|
const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) };
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
162
|
+
void this.enqueue(() => this.store.append(entry), error => {
|
|
163
|
+
this.failed ??= error instanceof Error ? error.message : String(error);
|
|
164
|
+
this.warn(`Memory could not be saved: ${this.failed}`);
|
|
165
|
+
});
|
|
198
166
|
this.root.push(entry); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
|
|
199
167
|
return entry;
|
|
200
168
|
}
|
|
201
169
|
node(part: Part) { return this.tree.get(key(part)); }
|
|
202
170
|
private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
|
|
171
|
+
/** Runs a store write at once when none is running, so a store that finishes its writes as it returns (FileStore) throws here
|
|
172
|
+
* as before; otherwise after the running ones, so writes land in the order Memory makes them. Returns the queued write, settled
|
|
173
|
+
* through `failed`, unless it finished at once. */
|
|
174
|
+
private enqueue(write: () => Promise<void> | undefined, failed: (error: unknown) => void) {
|
|
175
|
+
const run = () => this.failed ? undefined : write();
|
|
176
|
+
const started = this.writing ? this.writing.then(run) : run();
|
|
177
|
+
if (!started) return;
|
|
178
|
+
const settled = started.catch(failed);
|
|
179
|
+
const tail: Promise<void> = settled.catch(() => {}).then(() => { if (this.writing === tail) this.writing = undefined; });
|
|
180
|
+
this.writing = tail;
|
|
181
|
+
return settled;
|
|
182
|
+
}
|
|
203
183
|
/** Takes the saved view if it still tiles the log from the start, and returns how many messages it covers. */
|
|
204
|
-
private load() {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const rebuild = () => { this.warn(`Rebuilt the memory view: ${file} does not match the log.`); return 0; };
|
|
184
|
+
private load(view: string | undefined) {
|
|
185
|
+
if (view === undefined) return 0;
|
|
186
|
+
const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
|
|
208
187
|
let saved: unknown;
|
|
209
|
-
try { saved = JSON.parse(
|
|
188
|
+
try { saved = JSON.parse(view); } catch { return rebuild(); }
|
|
210
189
|
if (!Array.isArray(saved)) return rebuild();
|
|
211
190
|
const parts: Part[] = [];
|
|
212
191
|
let covered = 0;
|
|
@@ -223,8 +202,9 @@ export class Memory {
|
|
|
223
202
|
/** Saved when it merges: a view that only grew is the saved one plus a line per later message, as `load` replays it.
|
|
224
203
|
* The log stays authoritative, so a failed save only warns. */
|
|
225
204
|
private save() {
|
|
226
|
-
|
|
227
|
-
|
|
205
|
+
const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
|
|
206
|
+
try { void this.enqueue(() => this.store.saveView(JSON.stringify(this.view.map(p => [p.l, p.i]))), failed); }
|
|
207
|
+
catch (error) { failed(error); }
|
|
228
208
|
}
|
|
229
209
|
private push(i: number) {
|
|
230
210
|
const part = { l: 0, i };
|
|
@@ -304,7 +284,9 @@ export class Memory {
|
|
|
304
284
|
if (this.stopped) return;
|
|
305
285
|
if (!text) throw new Error('Compactor returned an empty summary.');
|
|
306
286
|
const node = { ...part, text, size: lineBytes(text) };
|
|
307
|
-
|
|
287
|
+
// A failed node write fails the build, which is retried.
|
|
288
|
+
const written = this.enqueue(() => this.store.appendNode(node), error => { throw error; });
|
|
289
|
+
if (written) await written;
|
|
308
290
|
this.tree.set(key(part), node); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
|
|
309
291
|
if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
|
|
310
292
|
if (!this.retryAt.size) this.lastError = undefined;
|
|
@@ -352,7 +334,7 @@ export class Memory {
|
|
|
352
334
|
if (n === 1) {
|
|
353
335
|
const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
|
|
354
336
|
if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
|
|
355
|
-
if (offset === undefined && text.length <= page) return `${id}+
|
|
337
|
+
if (offset === undefined && text.length <= page) return `${id}+1|${kind}: ${text}`;
|
|
356
338
|
if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
|
|
357
339
|
throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
|
|
358
340
|
// Never split a surrogate pair: a page starts on its first half and ends after its second.
|
|
@@ -361,7 +343,7 @@ export class Memory {
|
|
|
361
343
|
// A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
|
|
362
344
|
if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
|
|
363
345
|
const hint = text.length <= PAGE ? `\n[note: this message is only ${text.length.toLocaleString('en-US')} characters and fits in one zoom; offset/limit are for messages over ${PAGE.toLocaleString('en-US')}]` : '';
|
|
364
|
-
return `${id}+
|
|
346
|
+
return `${id}+1|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
|
|
365
347
|
}
|
|
366
348
|
if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
|
|
367
349
|
const l = Math.log2(n) - 1, i = 2 * id / n;
|
|
@@ -388,6 +370,6 @@ export class Memory {
|
|
|
388
370
|
}
|
|
389
371
|
async close() {
|
|
390
372
|
this.stopped = true; this.controller.abort(); clearTimeout(this.retryTimer);
|
|
391
|
-
this.events.emit('change'); await Promise.allSettled(this.busy.values());
|
|
373
|
+
this.events.emit('change'); await Promise.allSettled(this.busy.values()); await this.writing;
|
|
392
374
|
}
|
|
393
375
|
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Box, SelectList, Spacer, Text } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { ModelChoice } from './compactor.ts';
|
|
4
|
+
import { count, oneLine } from './inspector.ts';
|
|
5
|
+
import { frame, showModel } from './settings-page.ts';
|
|
6
|
+
|
|
7
|
+
/** What `/optchat` offers, in menu order; each is also `/optchat <action>`. */
|
|
8
|
+
export const ACTIONS = ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import'] as const;
|
|
9
|
+
export type MenuAction = typeof ACTIONS[number];
|
|
10
|
+
const DESCRIPTIONS: Record<MenuAction, string> = {
|
|
11
|
+
profile: 'Switch or create a profile',
|
|
12
|
+
settings: 'Models, subagent limits, memory options',
|
|
13
|
+
model: 'Compactor model and effort',
|
|
14
|
+
agents: 'Live agent tree and saved runs',
|
|
15
|
+
usage: 'Token usage and cost estimates',
|
|
16
|
+
activity: 'Memory gauge and running agents',
|
|
17
|
+
instructions: 'Edit this profile\'s AGENTS.md',
|
|
18
|
+
browse: 'Open a readable snapshot of memory',
|
|
19
|
+
import: 'Import history, or resume a paused import',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface MenuStatus { profile: string; messages: number; pending: number; compactor: ModelChoice; agents: ModelChoice; error?: string }
|
|
23
|
+
/** The status block as label and value; the plain menu outside the TUI shows the same rows. */
|
|
24
|
+
export const statusRows = (s: MenuStatus) => [
|
|
25
|
+
['Memory', `${count(s.messages)} messages · ${count(s.pending)} pending`],
|
|
26
|
+
// config.json only checks these are strings, so one with a newline or escape code must not break the block.
|
|
27
|
+
['Compactor', oneLine(showModel(s.compactor))],
|
|
28
|
+
['Agents', oneLine(showModel(s.agents))],
|
|
29
|
+
] as const;
|
|
30
|
+
const failing = (error: string) => `Summaries failing: ${oneLine(error)}`;
|
|
31
|
+
|
|
32
|
+
/** Framed like the settings page: title, the profile's status as aligned rows, then the actions with what each opens. */
|
|
33
|
+
export function optchatMenu(theme: Theme, title: string, status: MenuStatus | undefined, close: (action?: MenuAction) => void, redraw = () => {}) {
|
|
34
|
+
const list = new SelectList(ACTIONS.map(value => ({ value, label: value, description: DESCRIPTIONS[value] })), ACTIONS.length, {
|
|
35
|
+
selectedPrefix: t => theme.fg('accent', t), selectedText: t => theme.fg('accent', t),
|
|
36
|
+
description: t => theme.fg('muted', t), scrollInfo: t => theme.fg('muted', t), noMatch: t => theme.fg('muted', t) },
|
|
37
|
+
{ minPrimaryColumnWidth: 1, maxPrimaryColumnWidth: Math.max(...ACTIONS.map(a => a.length)) + 4 });
|
|
38
|
+
list.onSelect = item => close(ACTIONS.find(a => a === item.value));
|
|
39
|
+
list.onCancel = () => close();
|
|
40
|
+
const rows = status ? statusRows(status) : [];
|
|
41
|
+
const labels = Math.max(0, ...rows.map(([label]) => label.length));
|
|
42
|
+
const facts = status ? rows.map(([label, value]) => new Text(`${theme.fg('muted', label.padEnd(labels))} ${value}`, 1, 0)) : [new Text(theme.fg('muted', 'No active profile'), 1, 0)];
|
|
43
|
+
if (status?.error) facts.push(new Text(theme.fg('warning', failing(status.error)), 1, 0));
|
|
44
|
+
const actions = new Box(1, 0); actions.addChild(list);
|
|
45
|
+
const page = frame(theme, title, status?.profile, [new Spacer(1), ...facts, new Spacer(1), actions, new Spacer(1),
|
|
46
|
+
new Text(theme.fg('dim', '↑↓ select · Enter open · Esc close'), 1, 0)]);
|
|
47
|
+
return Object.assign(page, { handleInput: (data: string) => {
|
|
48
|
+
// j and k move too, as in Pi's own select dialog that this menu replaces.
|
|
49
|
+
const step = data === 'j' ? 1 : data === 'k' ? -1 : 0, at = ACTIONS.findIndex(a => a === list.getSelectedItem()?.value);
|
|
50
|
+
if (step) list.setSelectedIndex(Math.max(0, Math.min(ACTIONS.length - 1, at + step)));
|
|
51
|
+
else list.handleInput(data);
|
|
52
|
+
redraw();
|
|
53
|
+
} });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The menu in interactive Pi; elsewhere (RPC hosts) Pi's plain select with the same facts and actions. */
|
|
57
|
+
export function showMenu(ctx: ExtensionContext, title: string, status: MenuStatus | undefined): Promise<MenuAction | undefined> {
|
|
58
|
+
if (ctx.mode === 'tui') return ctx.ui.custom<MenuAction | undefined>((tui, theme, _keys, done) => optchatMenu(theme, title, status, done, () => tui.requestRender()));
|
|
59
|
+
const facts = status ? [status.profile, ...statusRows(status).map(([label, value]) => `${label}: ${value}`), ...status.error ? [failing(status.error)] : []] : ['No active profile'];
|
|
60
|
+
return ctx.ui.select([title, ...facts].join('\n'), [...ACTIONS]).then(choice => ACTIONS.find(a => a === choice));
|
|
61
|
+
}
|
package/src/profiles.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
|
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import type { ModelChoice } from './compactor.ts';
|
|
7
7
|
import { record } from './cache.ts';
|
|
8
|
-
import { atomicWrite } from './
|
|
8
|
+
import { atomicWrite, type Store } from './store.ts';
|
|
9
9
|
import { DEFAULT_SETTINGS, readSettings, type Settings } from './settings.ts';
|
|
10
10
|
|
|
11
11
|
export const isWindows = process.platform === 'win32';
|
|
@@ -127,3 +127,18 @@ export async function lockProfile(dir: string, description: string) {
|
|
|
127
127
|
if (!isWindows) { try { chmodSync(socketPath, 0o600); } catch (error) { await unlock(); throw error; } }
|
|
128
128
|
return unlock;
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/** How often a held lease is renewed; a store lets one lapse after about 30 seconds without renewal. */
|
|
132
|
+
export const HEARTBEAT_MS = 10_000;
|
|
133
|
+
/** Holds the profile until the returned function is called: through the store's lease if it has one, else the machine-local lock. */
|
|
134
|
+
export async function holdProfile(store: Pick<Store, 'lock'>, dir: string, profile: string, description: string, warn: (text: string) => void) {
|
|
135
|
+
const { lock } = store;
|
|
136
|
+
if (!lock) return lockProfile(dir, description);
|
|
137
|
+
const owner = await lock.lease(profile, description);
|
|
138
|
+
if (owner !== undefined) throw new ProfileBusyError(owner);
|
|
139
|
+
const beat = setInterval(() => {
|
|
140
|
+
lock.heartbeat().catch((error: unknown) => { warn(`Could not renew the profile lease: ${error instanceof Error ? error.message : String(error)}`); });
|
|
141
|
+
}, HEARTBEAT_MS);
|
|
142
|
+
beat.unref();
|
|
143
|
+
return async () => { clearInterval(beat); await lock.release(); };
|
|
144
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -70,6 +70,8 @@ further along than it was, and never narrate the process of summarizing
|
|
|
70
70
|
("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
|
|
71
71
|
a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
|
|
72
72
|
characters cost 2-4 bytes.`;
|
|
73
|
+
/** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
|
|
74
|
+
export const AT_WORK = '[OptChat status] Agents at work now: ';
|
|
73
75
|
export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
|
|
74
76
|
never ends. Each turn starts with the view below, followed by the user's new
|
|
75
77
|
message.
|
|
@@ -84,7 +86,8 @@ message starting "[id] ", between your tool calls or as a new turn. Never wait
|
|
|
84
86
|
for one (no sleep, no polling): go on, or end your turn and tell the user what
|
|
85
87
|
is running. The same goes for anything slow, such as CI or a deploy: don't sit
|
|
86
88
|
polling it yourself at the end of a task, start a subagent to watch it, and
|
|
87
|
-
move on
|
|
89
|
+
move on. A message starting "[OptChat status]" is OptChat's own note on which
|
|
90
|
+
subagents are running, not the user.`;
|
|
88
91
|
export const VIEW_DOC = `The view: the whole chat between OptChat and the user, oldest first, inside
|
|
89
92
|
<chat> tags, as one-line summaries. Each line is
|
|
90
93
|
|
package/src/report-message.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { Box, Markdown, Text } from '@earendil-works/pi-tui';
|
|
3
|
-
import { REPORT_TYPE, textContent } from './transcript.ts';
|
|
3
|
+
import { REPORT_TYPE, textContent, withoutFullChat } from './transcript.ts';
|
|
4
4
|
|
|
5
5
|
const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ export interface ReportDetails { count?: number }
|
|
|
9
9
|
|
|
10
10
|
/** Split "[id] Message from subagent (still running): body" into a short label and the body. */
|
|
11
11
|
export function reportParts(text: string, count = 1) {
|
|
12
|
+
text = withoutFullChat(text); // For the agent: the user has the Agents page.
|
|
12
13
|
if (count > 1) return { label: `${count} subagent reports`, body: text };
|
|
13
14
|
const match = HEAD.exec(text);
|
|
14
15
|
if (!match) return { label: 'subagent', body: text };
|
package/src/runs.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
5
|
-
import { atomicWrite } from './
|
|
5
|
+
import { atomicWrite } from './store.ts';
|
|
6
6
|
import { record } from './cache.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
8
|
|
package/src/settings-page.ts
CHANGED
|
@@ -11,7 +11,7 @@ const ROLES: Record<Role, { label: string; description: string; applies: string
|
|
|
11
11
|
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
|
|
12
12
|
};
|
|
13
13
|
const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
|
|
14
|
-
const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
14
|
+
export const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
15
15
|
/** The value column: the model, then whether it is the default (shown short, without its provider). */
|
|
16
16
|
const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
|
|
17
17
|
? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
|
|
@@ -114,9 +114,9 @@ function update(o: Options, patch: Partial<ProfileConfig>) {
|
|
|
114
114
|
return undefined;
|
|
115
115
|
}
|
|
116
116
|
/** Bordered, titled like Pi's own dialogs. */
|
|
117
|
-
function frame(theme: Theme, title: string, profile: string, body: Component[]) {
|
|
117
|
+
export function frame(theme: Theme, title: string, profile: string | undefined, body: Component[]) {
|
|
118
118
|
const page = new Container();
|
|
119
|
-
for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${theme.fg('muted', ` · ${profile}`)}`, 1, 0),
|
|
119
|
+
for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${profile ? theme.fg('muted', ` · ${profile}`) : ''}`, 1, 0),
|
|
120
120
|
...body, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
|
|
121
121
|
return page;
|
|
122
122
|
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import type { Entry, Summary } from './memory.ts';
|
|
4
|
+
|
|
5
|
+
/** The image types memory keeps, and the file extension each is saved under. */
|
|
6
|
+
export const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } as const;
|
|
7
|
+
export type Mime = keyof typeof EXTENSIONS;
|
|
8
|
+
export const isMime = (mime: string): mime is Mime => Object.hasOwn(EXTENSIONS, mime);
|
|
9
|
+
const MIMES = new Map<string, Mime>((Object.keys(EXTENSIONS) as Mime[]).map(mime => [EXTENSIONS[mime], mime]));
|
|
10
|
+
|
|
11
|
+
/** What a store gives back when a memory opens. Memory validates every record itself; `view` is the JSON `saveView` was last given. */
|
|
12
|
+
export interface Saved { entries: unknown[]; tree: unknown[]; view?: string }
|
|
13
|
+
/** A write is done when it returns: a store that writes behind returns a promise instead, which Memory awaits in order. */
|
|
14
|
+
type Write = Promise<void> | undefined;
|
|
15
|
+
type Maybe<T> = T | Promise<T>;
|
|
16
|
+
|
|
17
|
+
/** Cross-machine exclusivity. Implement it only if you want it: without it OptChat keeps its machine-local lock (a socket in the
|
|
18
|
+
* profile, or a named pipe on Windows), so two Pis on one machine still can't share a profile. */
|
|
19
|
+
export interface Lease {
|
|
20
|
+
/** Takes the profile for `holder` (who and where, shown to anyone refused), or returns the current holder.
|
|
21
|
+
* A lease not renewed for about 30 seconds may be taken by someone else. */
|
|
22
|
+
lease(profile: string, holder: string): Promise<string | undefined>;
|
|
23
|
+
/** Renews the lease. OptChat calls it every 10 seconds while it holds the profile and only warns when it fails, so fencing is
|
|
24
|
+
* the store's job: once its lease may have lapsed, it must refuse writes (for example with a fencing token checked on each one),
|
|
25
|
+
* since a paused process cannot be trusted to stop itself in time. */
|
|
26
|
+
heartbeat(): Promise<void>;
|
|
27
|
+
release(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where one memory keeps what travels between machines: its message log, summary nodes, saved view and images. Everything else
|
|
31
|
+
* in a profile (runs, recovery journals, the active-memory pointer and imports, usage, settings, AGENTS.md) stays on the machine.
|
|
32
|
+
* Memory loads once when it opens, then works in RAM and issues every write in order. */
|
|
33
|
+
export interface Store {
|
|
34
|
+
load(): Maybe<Saved>;
|
|
35
|
+
/** Appends a message. Throws, writing nothing, if someone else appended since this store loaded. */
|
|
36
|
+
append(entry: Entry): Write;
|
|
37
|
+
/** Appends a summary node. */
|
|
38
|
+
appendNode(node: Summary): Write;
|
|
39
|
+
/** Replaces the saved view. */
|
|
40
|
+
saveView(view: string): Write;
|
|
41
|
+
putImage(name: string, mimeType: Mime, data: Uint8Array): Write;
|
|
42
|
+
image(name: string): Maybe<{ mimeType: Mime; data: Uint8Array } | undefined>;
|
|
43
|
+
lock?: Lease;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The one place a profile's store is chosen. A `store` setting will plug in here; until then every profile keeps its memory in files. */
|
|
47
|
+
export const storeFor = (directory: string, warn: (s: string) => void = console.error): Store => new FileStore(directory, warn);
|
|
48
|
+
|
|
49
|
+
export function localDay(date = new Date()) {
|
|
50
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
export function atomicWrite(file: string, text: string | Uint8Array) {
|
|
53
|
+
mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
|
|
54
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
55
|
+
// One read-write handle: Windows refuses fsync on a read-only one, and it cannot open a directory at all, so there the file flush is the whole guarantee.
|
|
56
|
+
const fd = openSync(temporary, 'w+', 0o600);
|
|
57
|
+
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
58
|
+
renameSync(temporary, file);
|
|
59
|
+
if (process.platform === 'win32') return;
|
|
60
|
+
const parent = openSync(resolve(file, '..'), 'r');
|
|
61
|
+
try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
62
|
+
}
|
|
63
|
+
const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
|
|
64
|
+
/** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. A write
|
|
65
|
+
* cut short is truncated away before the error is thrown. Returns the new size. */
|
|
66
|
+
export function appendJson(file: string, value: unknown, size?: number) {
|
|
67
|
+
const fd = openSync(file, 'a+', 0o600);
|
|
68
|
+
try {
|
|
69
|
+
const length = fstatSync(fd).size, last = Buffer.alloc(1);
|
|
70
|
+
if (size !== undefined && length !== size) throw otherWriter(file);
|
|
71
|
+
const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
|
|
72
|
+
const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
|
|
73
|
+
// A cut-short write is undone, so the file keeps the size the caller checks against. Windows refuses to truncate through an
|
|
74
|
+
// append handle, so this goes by path; if the undo fails too, the write's own error is still the one thrown.
|
|
75
|
+
try { if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`); }
|
|
76
|
+
catch (error) { try { truncateSync(file, length); } catch {} throw error; }
|
|
77
|
+
fsyncSync(fd);
|
|
78
|
+
return fstatSync(fd).size;
|
|
79
|
+
} finally { closeSync(fd); }
|
|
80
|
+
}
|
|
81
|
+
function records(dir: string, warn: (s: string) => void): unknown[] {
|
|
82
|
+
if (!existsSync(dir)) return [];
|
|
83
|
+
const result: unknown[] = [];
|
|
84
|
+
for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
|
|
85
|
+
const file = join(dir, name);
|
|
86
|
+
for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
try { result.push(JSON.parse(line)); }
|
|
89
|
+
catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Memory as files in one directory: `main/` and `tree/` hold dated JSONL, each line fsynced as it is appended; `view.json` and
|
|
96
|
+
* `images/<hash>.<ext>` are replaced atomically. Every write is done when it returns. */
|
|
97
|
+
export class FileStore implements Store {
|
|
98
|
+
/** Each log file's size as this store last left it, so a write by another process is refused instead of interleaved. */
|
|
99
|
+
private readonly lastSeenBytes = new Map<string, number>();
|
|
100
|
+
constructor(readonly directory: string, private readonly warn: (s: string) => void = console.error) {}
|
|
101
|
+
load(): Saved {
|
|
102
|
+
for (const sub of ['main', 'tree']) mkdirSync(join(this.directory, sub), { recursive: true, mode: 0o700 });
|
|
103
|
+
const main = join(this.directory, 'main'), entries = records(main, this.warn);
|
|
104
|
+
for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
|
|
105
|
+
const view = join(this.directory, 'view.json');
|
|
106
|
+
// An unreadable view is rebuilt, as a damaged one is.
|
|
107
|
+
let saved: string | undefined;
|
|
108
|
+
if (existsSync(view)) try { saved = readFileSync(view, 'utf8'); } catch { saved = ''; }
|
|
109
|
+
return { entries, tree: records(join(this.directory, 'tree'), this.warn), view: saved };
|
|
110
|
+
}
|
|
111
|
+
append(entry: Entry): undefined {
|
|
112
|
+
const file = join(this.directory, 'main', `${localDay()}.jsonl`);
|
|
113
|
+
if (!this.lastSeenBytes.has(file)) this.checkLog(file);
|
|
114
|
+
this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
|
|
115
|
+
}
|
|
116
|
+
private checkLog(next: string) {
|
|
117
|
+
const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
|
|
118
|
+
if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
|
|
119
|
+
}
|
|
120
|
+
appendNode(node: Summary): undefined { appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node); }
|
|
121
|
+
saveView(view: string): undefined { atomicWrite(join(this.directory, 'view.json'), view); }
|
|
122
|
+
putImage(name: string, mimeType: Mime, data: Uint8Array): undefined { atomicWrite(join(this.directory, 'images', `${name}.${EXTENSIONS[mimeType]}`), data); }
|
|
123
|
+
image(name: string) {
|
|
124
|
+
let file: string | undefined;
|
|
125
|
+
try { file = readdirSync(join(this.directory, 'images')).find(f => f.startsWith(`${name}.`) && MIMES.has(f.slice(name.length + 1))); } catch { return undefined; }
|
|
126
|
+
const mimeType = file && MIMES.get(file.slice(name.length + 1));
|
|
127
|
+
return file && mimeType ? { mimeType, data: readFileSync(join(this.directory, 'images', file)) } : undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { Type } from 'typebox';
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
2
3
|
import { flat, PAGE, start, type Memory } from './memory.ts';
|
|
3
4
|
import { loadImages } from './images.ts';
|
|
5
|
+
import { runTranscript } from './transcript.ts';
|
|
4
6
|
export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} });
|
|
5
7
|
export const SEARCH_PAGE = 20;
|
|
6
8
|
const SNIPPET = 200;
|
|
@@ -22,20 +24,39 @@ export function searchPage(memory: Memory, text: string, before?: number) {
|
|
|
22
24
|
return `${hits.length} ${older}${hits.length === 1 ? 'message contains' : 'messages contain'} "${text}", newest first:\n${lines.join('\n')}${more}`;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
const zoomParameters = Type.Object({ id: Type.Union([Type.Integer({ minimum: 0 }), Type.String({ minLength: 1 })]), n: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
28
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: PAGE })) });
|
|
29
|
+
|
|
30
|
+
/** A page of a run's transcript, saying where the next one starts. */
|
|
31
|
+
export function runPage(text: string, offset = 0, limit = PAGE) {
|
|
32
|
+
if (offset > text.length) throw new Error(`This transcript has ${text.length} characters; offset must be 0 to ${text.length}.`);
|
|
33
|
+
// Never split a surrogate pair: a page starts on its first half and ends after its second.
|
|
34
|
+
const from = /[\udc00-\udfff]/.test(text[offset] ?? '') ? offset - 1 : offset;
|
|
35
|
+
let to = Math.min(text.length, from + limit);
|
|
36
|
+
if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
|
|
37
|
+
return `${text.slice(from, to)}\n[characters ${from}-${to} of ${text.length}${to < text.length ? `; go on with offset ${to}` : ''}]`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `runs` gives a subagent's messages by run id, live or finished; undefined for no such run. */
|
|
41
|
+
export function memoryTools(memory: () => Memory, runs?: (id: string) => readonly AgentMessage[] | undefined) {
|
|
26
42
|
return [
|
|
27
|
-
{ name: 'zoom', label: 'Zoom memory', description: `Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole. A message over ${PAGE.toLocaleString('en-US')} characters comes in pages; offset and limit (characters) read any part of it, and are not needed for a shorter one. A message's images come back with it.`,
|
|
28
|
-
parameters:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
{ name: 'zoom', label: 'Zoom memory', description: `Open the line id+n of the view into the two lines of n/2 under it; n = 1 (the default) gives the message whole. A message over ${PAGE.toLocaleString('en-US')} characters comes in pages; offset and limit (characters) read any part of it, and are not needed for a shorter one. A message's images come back with it. zoom("<run id>") gives a subagent's whole chat so far, in the same pages.`,
|
|
44
|
+
parameters: zoomParameters,
|
|
45
|
+
async execute(_id: string, { id, n = 1, offset, limit }: Static<typeof zoomParameters>) {
|
|
46
|
+
if (typeof id === 'string') {
|
|
47
|
+
const messages = runs?.(id);
|
|
48
|
+
if (messages) return result(runPage(runTranscript(messages), offset, limit));
|
|
49
|
+
// A model may quote a message id; eight digits can be a run id (src/agents.ts), so those never open a message.
|
|
50
|
+
if (!/^\d+$/.test(id) || id.length === 8) throw new Error(`No run ${id}.`);
|
|
51
|
+
}
|
|
52
|
+
const m = memory(), text = m.zoom(Number(id), n, offset, limit), page = result(text);
|
|
32
53
|
// A message's images come back with its text, as read returns a PNG; summaries stay text.
|
|
33
|
-
return
|
|
54
|
+
return n === 1 ? { ...page, content: [...page.content, ...await loadImages(m.store, text)] } : page;
|
|
34
55
|
} },
|
|
35
56
|
{ name: 'date', label: 'Memory date', description: 'The date and time of message id.',
|
|
36
57
|
parameters: Type.Object({ id: Type.Integer({ minimum: 0 }) }),
|
|
37
58
|
async execute(_id: string, args: { id: number }) { return result(memory().date(args.id)); } },
|
|
38
|
-
];
|
|
59
|
+
] as const;
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
export const searchTool = (memory: () => Memory) => ({ name: 'search', label: 'Search memory',
|
package/src/transcript.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
|
|
5
|
-
import { CAP, cap, type Memory } from './memory.ts';
|
|
5
|
+
import { CAP, cap, flat, isView, type Memory } from './memory.ts';
|
|
6
|
+
import { AT_WORK } from './prompts.ts';
|
|
6
7
|
import { record } from './cache.ts';
|
|
7
8
|
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
8
9
|
import { imageRef, isImage } from './images.ts';
|
|
@@ -124,9 +125,45 @@ function latestExchange(branch: readonly SessionEntry[]) {
|
|
|
124
125
|
return latest;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
+
/** Ends every subagent report, so the parent knows it can check the report against what the child did. */
|
|
129
|
+
export const fullChat = (id: string) => `Full chat: zoom("${id}")`;
|
|
130
|
+
export const withoutFullChat = (text: string) => text.replace(/\n\nFull chat: zoom\("[\w-]+"\)$/gm, '');
|
|
131
|
+
const TASK = '\n</chat>\n\nYour task:\n';
|
|
132
|
+
/** Tool calls and results in a run transcript keep their head and tail, so a page holds many steps. */
|
|
133
|
+
const STEP = 1_000;
|
|
134
|
+
/** A subagent's chat for zoom, as `kind|text` lines: its task without the memory view, replies, tool calls and results, and what it was told. */
|
|
135
|
+
export function runTranscript(messages: readonly AgentMessage[]) {
|
|
136
|
+
const task = messages.find(message => message.role === 'user');
|
|
137
|
+
return messages.flatMap(message => {
|
|
138
|
+
if (message.role === 'user') {
|
|
139
|
+
// Only the task prompt holds the memory view (src/agents.ts): `${view}\n\nYour task:\n${task}`.
|
|
140
|
+
const text = textContent(message.content, false), at = text.indexOf(TASK);
|
|
141
|
+
return [`user|${message === task && isView(text) && at >= 0 ? text.slice(at + '\n</chat>\n\n'.length) : text}`];
|
|
142
|
+
}
|
|
143
|
+
if (message.role === 'toolResult') return [`echo|${cap(`${message.toolName}: ${textContent(message.content, false)}`, STEP)}`];
|
|
144
|
+
if (message.role !== 'assistant') return [];
|
|
145
|
+
return [...message.content.flatMap(block => block.type === 'text' && block.text.trim() ? [`talk|${block.text}`]
|
|
146
|
+
: block.type === 'toolCall' ? [`tool|${cap(`${block.name} ${JSON.stringify(block.arguments)}`, STEP)}`] : []),
|
|
147
|
+
...message.stopReason === 'error' || message.stopReason === 'aborted' ? [`echo|Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`] : []];
|
|
148
|
+
}).join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The status line naming running agents by id and the first words of their task; none while nothing runs. */
|
|
152
|
+
export function atWork(runs: readonly { id: string; task: string }[]) {
|
|
153
|
+
if (!runs.length) return undefined;
|
|
154
|
+
return AT_WORK + runs.map(({ id, task }) => {
|
|
155
|
+
const words = flat(task).replaceAll('"', "'").split(/\s+/).filter(Boolean), start = words.slice(0, 6).join(' ');
|
|
156
|
+
// Six words, but never more than 60 characters: a task can be one long word.
|
|
157
|
+
const preview = [...start].slice(0, 60).join('');
|
|
158
|
+
return `${id} "${preview}${words.length > 6 || preview.length < start.length ? '…' : ''}"`;
|
|
159
|
+
}).join(', ') + '.';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Keep one completed exchange plus the current run; all other history comes from the view.
|
|
163
|
+
* `status` goes last, after everything cached, so it never moves the cached prefix (see cachePayload). It comes with new input only
|
|
164
|
+
* (the user, a report, a restart note): a call that follows a tool result is the same turn, which already had it. */
|
|
128
165
|
export function buildContext(canonical: AgentMessage[], run: AgentMessage[], view: string, prompt: string,
|
|
129
|
-
previous: readonly AgentMessage[] = []): AgentMessage[] {
|
|
166
|
+
previous: readonly AgentMessage[] = [], status?: string): AgentMessage[] {
|
|
130
167
|
const system = getCurrentSystemMessage(canonical);
|
|
131
168
|
const head: SystemMessage = { role: 'system', content: prompt, toolsAdded: system?.toolsAdded, timestamp: 0 };
|
|
132
169
|
if (!run.some(m => m.role === 'user')) throw new Error('OptChat has no current user message; refusing to send historical context.');
|
|
@@ -134,7 +171,9 @@ export function buildContext(canonical: AgentMessage[], run: AgentMessage[], vie
|
|
|
134
171
|
const messages = [...previous, ...run].filter(m => m.role !== 'system').map(message => {
|
|
135
172
|
if (message.role !== 'user' || injected) return message;
|
|
136
173
|
injected = true;
|
|
137
|
-
|
|
174
|
+
// OpenAI joins text blocks with nothing between them, so the user's words start their own paragraph after the view.
|
|
175
|
+
const [first, ...rest] = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;
|
|
176
|
+
return { ...message, content: [{ type: 'text' as const, text: view }, ...first?.type === 'text' ? [{ ...first, text: `\n\n${first.text}` }] : first ? [first] : [], ...rest] };
|
|
138
177
|
});
|
|
139
|
-
return [head, ...messages];
|
|
178
|
+
return [head, ...messages, ...status && messages.at(-1)?.role !== 'toolResult' ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: status }], timestamp: 0 }] : []];
|
|
140
179
|
}
|
package/src/usage.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import type { AssistantMessage, Usage } from '@earendil-works/pi-ai';
|
|
5
5
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import { record } from './cache.ts';
|
|
7
|
-
import { appendJson } from './
|
|
7
|
+
import { appendJson } from './store.ts';
|
|
8
8
|
|
|
9
9
|
export type UsageRole = 'main' | 'subagent' | 'compactor' | 'import';
|
|
10
10
|
export const ranges = ['This session', 'Last hour', 'Today', 'Last 7 days', 'All time'] as const;
|