pi-optchat 0.10.2 → 0.11.1

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/src/import/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { Memory } from '../memory.ts';
3
- import { bytes } from '../memory.ts';
3
+ import { bytes } from '../core/memory.ts';
4
4
  import { scanLocal, scanChatGPT, scanClaudeMemories, readConversation, type Conversation, type ImportedEntry, type Source } from './sources.ts';
5
5
  import { deduplicate, type ImportMode, type ImportJob, type ImportProgress } from './job.ts';
6
6
  import { selectMany } from './multi-select.ts';
@@ -64,7 +64,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
64
64
  if (choice !== 'Continue with supported records') return;
65
65
  }
66
66
  if (!entries.length) { ctx.ui.notify('No readable messages remain to import. Rescan or choose different conversations.', 'info'); return; }
67
- const { added, skipped } = deduplicate(memory.root, entries);
67
+ const existing = memory.entries(), { added, skipped } = deduplicate(existing, entries);
68
68
  if (!added.length) { ctx.ui.notify(`Nothing new to import (${skipped} messages already present).`, 'info'); return; }
69
69
  let mode: ImportMode = 'append';
70
70
  if (memory.root.length) {
@@ -75,7 +75,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
75
75
  if (!selected) return;
76
76
  mode = selected.startsWith('Rebuild') ? 'rebuild' : 'append';
77
77
  }
78
- const affected = mode === 'rebuild' ? [...memory.root, ...added] : added;
78
+ const affected = mode === 'rebuild' ? [...existing, ...added] : added;
79
79
  const inputBytes = affected.reduce((n, e) => n + bytes(e.text), 0);
80
80
  let nodes = 0;
81
81
  for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
package/src/inbox.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { record } from './cache.ts';
4
+ import { record } from './core/cache.ts';
5
5
  import { atomicWrite } from './store.ts';
6
6
  import type { Memory } from './memory.ts';
7
7
 
package/src/index.ts CHANGED
@@ -10,12 +10,15 @@ import { Memory } from './memory.ts';
10
10
  import { atomicWrite, storeFor } from './store.ts';
11
11
  import { createCompressor, type ModelChoice } from './compactor.ts';
12
12
  import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
13
- import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
14
- import { cachePayload, record } from './cache.ts';
13
+ import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './core/prompts.ts';
14
+ import { MASTER, modelsHint } from './prompts.ts';
15
+ import { cachePayload, record } from './core/cache.ts';
15
16
  import { saveImages } from './images.ts';
16
- import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, refreshedRun, refreshBudget, refreshFraction, refreshPoint, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
17
+ import { asUser, atWork, buildContext, logMessage, previousExchange, refreshFraction, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
18
+ import { boundedMessage, refreshedRun, refreshBudget, refreshPoint } from './core/messages.ts';
17
19
  import { registerReportRenderer, type ReportDetails } from './report-message.ts';
18
- import { memoryTools, result, searchTool } from './tools.ts';
20
+ import { memoryTools } from './tools.ts';
21
+ import { result, searchTool } from './core/tools.ts';
19
22
  import { Children, loadedBuiltins, modelsTool, refreshContextFiles, TASKS } from './agents.ts';
20
23
  import { isActiveRun, type RunInfo } from './runs.ts';
21
24
  import { exportBrowser } from './browser.ts';
@@ -44,10 +47,6 @@ async function printReply(text: string) {
44
47
  catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
45
48
  }
46
49
  }
47
- const toggle = (prompt: string, line: string, on: boolean, after: string) => on === prompt.includes(line) ? prompt : on ? prompt.replace(after, after + line) : prompt.replace(line, '');
48
- /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
49
- const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
50
- allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
51
50
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
52
51
 
53
52
  /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
@@ -173,13 +172,11 @@ export default function optchat(pi: ExtensionAPI) {
173
172
  }
174
173
  }
175
174
  };
176
- // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
177
- // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
175
+ // Shown as a dark background box, not as the user's own message.
178
176
  const sendReport = (report: Report) => {
179
177
  const { text, count } = report;
180
178
  report.steered = !idle();
181
- if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
182
- else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
179
+ pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
183
180
  };
184
181
  const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
185
182
  if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
@@ -236,10 +233,10 @@ export default function optchat(pi: ExtensionAPI) {
236
233
  const pending = join(dir, 'pending-reports.json');
237
234
  const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
238
235
  if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
239
- const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
236
+ const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, { onUsage: message => {
240
237
  usage.compression(message, 'compactor', sessionId);
241
238
  status(ctx);
242
- }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'), undefined, config.jobs);
239
+ }, accepted: () => config.summaryAcceptBytes }), { warn: warning => ctx.ui.notify(warning, 'error'), jobs: config.jobs });
243
240
  openingMemory = memory;
244
241
  const inbox = new Inbox(dir);
245
242
  const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
@@ -404,21 +401,14 @@ export default function optchat(pi: ExtensionAPI) {
404
401
  }
405
402
  return { action: 'continue' };
406
403
  });
407
- const startRun = (ctx: ExtensionContext) => {
408
- flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
409
- const config = active?.config;
410
- previous = config?.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), config.previousExchangeKB * 1000) : [];
411
- pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
412
- };
413
- // A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
414
- pi.on('agent_start', (_event, ctx) => {
415
- working = true; showTitle(ctx);
416
- if (active && !runStarted) startRun(ctx);
417
- });
404
+ pi.on('agent_start', (_event, ctx) => { working = true; showTitle(ctx); });
405
+ // Every run starts here, reports sent while Pi is idle too (Pi 1.2+).
418
406
  pi.on('before_agent_start', (event, ctx) => {
419
407
  if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
420
- startRun(ctx);
421
- const a = required();
408
+ const a = active;
409
+ flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
410
+ previous = a.config.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), a.config.previousExchangeKB * 1000) : [];
411
+ pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
422
412
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
423
413
  syncSearch(a.config);
424
414
  event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${modelsHint(a.config.subagent)}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
@@ -440,13 +430,12 @@ export default function optchat(pi: ExtensionAPI) {
440
430
  if (message.role === 'user') {
441
431
  try {
442
432
  const text = textContent(message.content);
443
- // A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
444
- if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) {
433
+ if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE) {
445
434
  // A report sent again after a lost steer can still arrive twice (Pi kept the old steer after an abort): drop the extra copy.
446
435
  // Copies are counted, so a later report that happens to repeat an earlier text still arrives.
447
436
  const receipt = reportReceipt(text), copies = run.slice(logged).filter(m => receipts.get(m) === receipt).length;
448
437
  if (reports.filter(r => !r.batch && r.text === text).length <= copies && (copies > 0 || active.memory.root.some(e => e.receipt === receipt)))
449
- return { message: bounded.role === 'custom' ? { ...bounded, content: DUPLICATE_REPORT, display: false } : { ...message, content: DUPLICATE_REPORT } };
438
+ return { message: { ...bounded, content: DUPLICATE_REPORT, display: false } };
450
439
  receipts.set(message, receipt);
451
440
  } else {
452
441
  // The inbox journaled the typed input: match without image references or Pi's image notes.
@@ -485,7 +474,7 @@ export default function optchat(pi: ExtensionAPI) {
485
474
  flush();
486
475
  }
487
476
  // Extensions that run after ours (Pi's built-in MCP adds <mcp_servers>) still change the prompt; send what Pi has now.
488
- // A prompt without our preamble (a report run started while Pi was idle) keeps the last one we built.
477
+ // A prompt without our preamble keeps the last one we built.
489
478
  const current = ctx.getSystemPrompt();
490
479
  if (current.startsWith(MASTER.slice(0, 40))) prompt = current;
491
480
  // A turn past its budget moves its steps into the view, keeping the task and the last tool call whole (#146).
@@ -498,7 +487,7 @@ export default function optchat(pi: ExtensionAPI) {
498
487
  ctx.ui.notify(`OptChat moved ${Math.round(size / 4000)}k tokens of this turn into memory (${((Date.now() - began) / 1000).toFixed(1)}s).`, 'info');
499
488
  }
500
489
  // Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached.
501
- return { messages: buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, promptFor(prompt, a.config), previous, atWork(a.children.working)) };
490
+ return { messages: buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, prompt, previous, atWork(a.children.working)) };
502
491
  } catch (error) {
503
492
  // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
504
493
  ctx.abort();
@@ -538,7 +527,6 @@ export default function optchat(pi: ExtensionAPI) {
538
527
  pi.registerTool(zoom); pi.registerTool(date);
539
528
  pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
540
529
  // The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
541
- // Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
542
530
  const syncSearch = ({ memorySearch }: ProfileConfig) => {
543
531
  const tools = pi.getActiveTools();
544
532
  if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
@@ -560,7 +548,7 @@ export default function optchat(pi: ExtensionAPI) {
560
548
  /** The settings page's options: every model Pi is logged in to, with the thinking levels it takes, sorted so each provider's models sit together. */
561
549
  const settingsOptions = (ctx: ExtensionContext, a: Active) => ({ profile: a.name, config: a.config,
562
550
  models: ctx.modelRegistry.getAvailable().map(m => ({ name: `${m.provider}/${m.id}`, thinking: getSupportedThinkingLevels(m) })).sort((x, y) => x.name.localeCompare(y.name)),
563
- save: (config: ProfileConfig) => { saveConfig(a.dir, config); syncSearch(config); } });
551
+ save: (config: ProfileConfig) => saveConfig(a.dir, config) });
564
552
  const pickModel = async (ctx: ExtensionContext, role: 'compactor' | 'subagent') => {
565
553
  const a = required();
566
554
  if (ctx.mode !== 'tui') throw new Error('Choosing a model requires interactive Pi. Edit config.json in the profile directory instead.');
@@ -621,9 +609,9 @@ export default function optchat(pi: ExtensionAPI) {
621
609
  if (!job) return;
622
610
  }
623
611
  if (!closed) { await a.memory.close(); closed = true; }
624
- const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
612
+ const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, { onUsage: message => {
625
613
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
626
- }, () => a.config.summaryAcceptBytes);
614
+ }, accepted: () => a.config.summaryAcceptBytes });
627
615
  const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.jobs, progress), signal);
628
616
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
629
617
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
package/src/memory.ts CHANGED
@@ -1,423 +1,25 @@
1
- import { EventEmitter } from 'node:events';
2
- import { FileStore, storeFor, type Saved, type Store } from './store.ts';
1
+ import { Memory as CoreMemory, VIEW, type Compressor, type MemoryOptions } from './core/memory.ts';
2
+ import type { Saved, Store } from './core/store.ts';
3
+ import { FileStore, storeFor } from './store.ts';
3
4
 
4
- export const NODE = 512;
5
- export const VIEW = 128_000;
6
- export const CAP = 30_000;
7
- /** The most characters of one message a leaf sends the compactor, so a huge paste can't overflow it; zoom still pages through all of it. */
8
- export const SOURCE = 100_000;
9
- /** The most characters one zoom into a message returns, so a page stays under the tool output CAP instead of losing its middle. */
10
- export const PAGE = 25_000;
11
- export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const;
12
- export type Kind = typeof KINDS[number];
13
- export interface Origin { source: 'claude' | 'claude-memory' | 'codex' | 'pi' | 'chatgpt'; conversation: string; message: string; title: string; project?: string }
14
- export interface Entry { i: number; kind: Kind; text: string; size: number; date: string; receipt?: string; origin?: Origin }
15
- export interface Part { l: number; i: number }
16
- export interface Summary extends Part { text: string; size: number }
17
- /** `part` is the node to build: a message's leaf, or the merge of its two halves. `call` is the tool call a tool result's leaf
18
- * answers, raw, since its own line is often not built yet (#153). */
19
- export interface Compression { context: string; source: string; part: Part; historical?: boolean; call?: { i: number; text: string } }
20
- export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
21
- /** A compressor throws this when the model itself gives no line for an input (a refusal, an empty reply, 5 cut-off replies):
22
- * asked again it fails alike, so the node keeps the start of its source instead of being retried forever (#193). */
23
- export class Refused extends Error {}
24
- const key = ({ l, i }: Part) => l * 2 ** 40 + i;
25
- const UNBUILT = '(not summarized yet: zoom it)';
26
- const CLIPPED = '(not summarized: zoom it)';
27
- export const start = ({ l, i }: Part) => i * 2 ** l;
28
- export const end = (part: Part) => start(part) + 2 ** part.l;
29
- export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
30
- const UNBUILT_BYTES = bytes(UNBUILT);
31
- export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
32
- /** The first NODE bytes, without a split character at the end. */
33
- export const cut = (s: string) => Buffer.from(s).subarray(0, NODE).toString('utf8').replace(/\uFFFD+$/, '');
34
- const lineBytes = (s: string) => bytes(flat(s));
35
- const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
36
- const VIEW_OPEN = '<chat>\n';
37
- const VIEW_FRAME_BYTES = bytes(`${VIEW_OPEN}</chat>`);
38
- export const isView = (text: string) => text.startsWith(VIEW_OPEN);
39
- export function cap(text: string, limit = CAP) {
40
- if (text.length <= limit) return text;
41
- const half = Math.floor((limit - notice(text.length).length) / 2);
42
- const head = text.slice(0, /[\ud800-\udbff]/.test(text[half - 1]) ? half - 1 : half);
43
- const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
44
- return head + notice(text.length - head.length - tail.length) + tail;
45
- }
46
- /** 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
47
- * long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
48
- export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
49
- let best = -1, due = -Infinity;
50
- for (let j = 0; j + 1 < view.length; j++) {
51
- const a = view[j], b = view[j + 1];
52
- if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
53
- const age = (total - (end(b) - 1)) / 2 ** a.l;
54
- if (age > due && built({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
55
- }
56
- return best;
57
- }
58
- /** A view (recipe §3.2): lines that tile the log, oldest first. It grows a line per message and, once over `high` bytes or when
59
- * forced, merges its most due pairs in one batch down to `low`, merging what it can at each change until then. */
60
- class Sawtooth {
61
- readonly parts: Part[] = [];
62
- private measured = 0;
63
- merging = false;
64
- constructor(private readonly high: number, private readonly low: number, private readonly node: (part: Part) => Summary | undefined) {}
65
- private lineSize(part: Part) { return bytes(`${start(part)}+${2 ** part.l}|\n`) + (this.node(part)?.size ?? UNBUILT_BYTES); }
66
- get size() { return VIEW_FRAME_BYTES + this.measured + (this.parts.length ? 0 : 1); } // render() keeps a blank line in an empty view.
67
- push(part: Part) { this.parts.push(part); this.measured += this.lineSize(part); }
68
- /** The line covering message `at`. The lines tile the log in order, so a binary search finds it. */
69
- covering(at: number): Part | undefined {
70
- for (let lo = 0, hi = this.parts.length - 1; lo <= hi;) {
71
- const mid = (lo + hi) >> 1, p = this.parts[mid];
72
- if (end(p) <= at) lo = mid + 1;
73
- else if (start(p) > at) hi = mid - 1;
74
- else return p;
75
- }
76
- }
77
- /** A message got its node. Its line is usually still shown, but after a damaged tree file a saved parent can already hide it. */
78
- built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.node({ l: 0, i })!.size - UNBUILT_BYTES; }
79
- /** Returns whether any lines merged. */
80
- fit(total: number, force = false) {
81
- const lines = this.parts.length;
82
- if (force || this.size > this.high) this.merging = true;
83
- for (let best: number; this.merging && this.size > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
84
- const a = this.parts[best], b = this.parts[best + 1], parent = { l: a.l + 1, i: a.i / 2 };
85
- this.measured += this.lineSize(parent) - this.lineSize(a) - this.lineSize(b);
86
- this.parts.splice(best, 2, parent);
87
- }
88
- if (this.size <= this.low) this.merging = false;
89
- return this.parts.length < lines;
90
- }
91
- }
92
- function object(value: unknown): value is Record<string, unknown> {
93
- return typeof value === 'object' && value !== null;
94
- }
95
- export function isEntry(value: unknown): value is Entry {
96
- return object(value) && Number.isSafeInteger(value.i) && typeof value.kind === 'string'
97
- && KINDS.some(kind => kind === value.kind)
98
- && typeof value.text === 'string' && typeof value.date === 'string';
99
- }
100
- function isSummary(value: unknown): value is Summary {
101
- return object(value) && Number.isSafeInteger(value.l) && Number.isSafeInteger(value.i)
102
- && typeof value.text === 'string';
103
- }
104
-
105
- /** The log is authoritative. The tree and the view follow the recipe: the view grows a line per message and, once it passes
106
- * `budget`, merges its most due pairs in one batch down to half of it, so between batches it only grows at its end.
107
- * Compactions get their own view (recipe §4): the chat's merged further, to between an eighth and a quarter of the budget,
108
- * and again whenever the chat's merges, so parallel compactions read it from the cache. */
109
- export class Memory {
110
- readonly root: Entry[] = [];
111
- readonly tree = new Map<number, Summary>();
112
- private readonly events = new EventEmitter();
113
- private readonly controller = new AbortController();
114
- private readonly busy = new Map<number, Promise<void>>();
115
- private readonly retryAt = new Map<number, number>();
116
- private readonly reported = new Set<number>();
117
- /** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
118
- private readonly unbuilt = new Set<number>();
119
- private readonly merges = new Map<number, Part>();
120
- private retryTimer?: ReturnType<typeof setTimeout>;
121
- /** The most summaries owed at once since none were last owed. */
122
- private peak = 0;
123
- private scheduled = false;
124
- private stopped = false;
125
- private readonly chat: Sawtooth;
126
- private readonly compaction: Sawtooth;
127
- /** 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. */
128
- private writing?: Promise<void>;
129
- private failed?: string;
130
- /** Where this memory's local files live: all of it for a FileStore, and the browser page, runs and imports for any store. */
5
+ /** A memory in a profile directory, which also holds its local files (browser page, runs, imports) whatever the store. */
6
+ export class Memory extends CoreMemory {
131
7
  readonly directory: string;
132
- readonly store: Store;
133
- lastError?: string;
134
-
135
8
  /** Opens the memory in `directory` through the store `storeFor` picks, after its load. */
136
- static async open(directory: string, compress: Compressor, warn: (s: string) => void = console.error, budget = VIEW, jobs = 8) {
9
+ static async open(directory: string, compress: Compressor, { warn = console.error, budget, jobs, retryMs }: MemoryOptions = {}) {
137
10
  const store = storeFor(directory, warn);
138
- return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
139
- }
140
- /** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
141
- * waits behind. Chat and imports pass the "Summaries at once" setting. */
142
- constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
143
- private readonly warn: (s: string) => void = console.error,
144
- readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
145
- this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
146
- this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
147
- let saved: Saved;
148
- if (typeof source === 'string') { const store = new FileStore(source, warn); this.directory = source; this.store = store; saved = store.load(); }
149
- else ({ directory: this.directory, store: this.store, saved } = source);
150
- const log = saved.entries;
151
- const entries = log.every(isEntry) ? log.sort((a, b) => a.i - b.i) : undefined;
152
- if (!entries || entries.some((entry, i) => entry.i !== i)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
153
- for (const value of entries) this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
154
- for (const value of saved.tree) {
155
- if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
156
- throw new Error('Invalid OptChat summary record.');
157
- this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
158
- }
159
- for (const node of this.tree.values()) this.queueParent(node);
160
- for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
161
- // Refolding the log gives different views than the live ones, and every cache entry would miss, so the views are saved.
162
- const loaded = this.load(saved.view);
163
- for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
164
- this.fit(); this.save(); this.schedule();
165
- }
166
- append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) {
167
- if (this.stopped) throw new Error('Memory is closed.');
168
- if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`);
169
- const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) };
170
- void this.enqueue(() => this.store.append(entry), error => {
171
- this.failed ??= error instanceof Error ? error.message : String(error);
172
- this.warn(`Memory could not be saved: ${this.failed}`);
173
- });
174
- this.root.push(entry); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
175
- return entry;
176
- }
177
- node(part: Part) { return this.tree.get(key(part)); }
178
- private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
179
- /** Runs a store write at once when none is running, so a store that finishes its writes as it returns (FileStore) throws here
180
- * as before; otherwise after the running ones, so writes land in the order Memory makes them. Returns the queued write, settled
181
- * through `failed`, unless it finished at once. */
182
- private enqueue(write: () => Promise<void> | undefined, failed: (error: unknown) => void) {
183
- const run = () => this.failed ? undefined : write();
184
- const started = this.writing ? this.writing.then(run) : run();
185
- if (!started) return;
186
- const settled = started.catch(failed);
187
- const tail: Promise<void> = settled.catch(() => {}).then(() => { if (this.writing === tail) this.writing = undefined; });
188
- this.writing = tail;
189
- return settled;
190
- }
191
- /** Takes the saved views if they still tile the log from the start, and returns how many messages they cover. */
192
- private load(view: string | undefined) {
193
- if (view === undefined) return 0;
194
- let saved: unknown;
195
- try { saved = JSON.parse(view); } catch { saved = undefined; }
196
- const chat = this.tiling(saved);
197
- if (!chat) { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; }
198
- for (const part of chat.parts) this.chat.push(part);
199
- this.chat.merging = chat.merging;
200
- // An older view.json has only the chat's view: the compactions' is refolded from it, a cold prefix once.
201
- const compaction = object(saved) ? this.tiling(saved.compaction) : undefined;
202
- if (compaction?.covered === chat.covered) { for (const part of compaction.parts) this.compaction.push(part); this.compaction.merging = compaction.merging; }
203
- else { for (const part of chat.parts) this.compaction.push(part); this.compaction.fit(chat.covered, true); }
204
- return chat.covered;
205
- }
206
- /** A saved view, `{ parts, merging }` or an older bare list of parts, if it tiles the log from the start. */
207
- private tiling(saved: unknown) {
208
- const merging = object(saved) && saved.merging === true;
209
- if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
210
- if (!Array.isArray(saved)) return;
211
- const parts: Part[] = [];
212
- let covered = 0;
213
- for (const value of saved) {
214
- const [l, i] = Array.isArray(value) ? value : [];
215
- const part = { l, i };
216
- if (!Number.isSafeInteger(l) || !Number.isSafeInteger(i) || l < 0 || start(part) !== covered || end(part) > this.root.length || (l > 0 && !this.node(part)))
217
- return;
218
- parts.push(part); covered = end(part);
219
- }
220
- return { parts, merging, covered };
221
- }
222
- /** Saved when either view merges or changes batch state: a view that only grew is the saved one plus later messages, as `load`
223
- * replays it. The log stays authoritative, so a failed save only warns. */
224
- private save() {
225
- const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
226
- const saved = ({ parts, merging }: Sawtooth) => ({ parts: parts.map(p => [p.l, p.i]), merging });
227
- try { void this.enqueue(() => this.store.saveView(JSON.stringify({ ...saved(this.chat), compaction: saved(this.compaction) })), failed); }
228
- catch (error) { failed(error); }
11
+ return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs, retryMs);
229
12
  }
230
- private push(i: number) {
231
- const part = { l: 0, i };
232
- this.chat.push(part); this.compaction.push(part);
233
- if (!this.node(part)) this.unbuilt.add(i);
234
- }
235
- get view(): readonly Part[] { return this.chat.parts; }
236
- render() { return `${VIEW_OPEN}${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
237
- get ready() { return this.view.every(p => this.node(p)); }
238
- get pending() { return this.unbuilt.size; }
239
- get active() { return this.busy.size; }
240
- get size() { return this.chat.size; }
241
- /** Summaries built out of the backlog since it was last empty, and when the next failed one is retried. */
242
- progress(now = Date.now()) {
243
- const due = Math.min(...[...this.retryAt.values()].filter(t => t > now));
244
- return { done: this.peak - this.owed(), total: this.peak, retryIn: Number.isFinite(due) ? due - now : undefined };
245
- }
246
- private owed() { return this.expectedNodes() - this.tree.size; }
247
- onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
248
- /** The view line covering message `at`. */
249
- covering(at: number) { return this.chat.covering(at); }
250
- /** Returns whether either view, or its batch state, changed. */
251
- private fit(total = this.root.length) {
252
- const merging = [this.chat.merging, this.compaction.merging];
253
- const merged = this.chat.fit(total);
254
- const compacted = this.compaction.fit(total, merged);
255
- const owed = this.owed();
256
- this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
257
- this.events.emit('change');
258
- return merged || compacted || merging[0] !== this.chat.merging || merging[1] !== this.compaction.merging;
259
- }
260
- private schedule() {
261
- if (this.scheduled || this.stopped) return;
262
- this.scheduled = true;
263
- queueMicrotask(() => { this.scheduled = false; this.pump(); });
264
- }
265
- private pump() {
266
- if (this.stopped) return;
267
- // One clock reading: skipping a part and arming its retry timer must agree on what is due.
268
- const now = Date.now();
269
- const run = (part: Part) => {
270
- const id = key(part);
271
- if (this.busy.has(id) || (this.retryAt.get(id) ?? 0) > now) return;
272
- const promise = this.build(part).catch(error => {
273
- if (this.stopped) return;
274
- this.lastError = error instanceof Error ? error.message : String(error);
275
- if (!this.reported.has(id)) { this.reported.add(id); this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${this.lastError}`); }
276
- this.retryAt.set(id, Date.now() + this.retryMs);
277
- if (!this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, this.retryMs);
278
- // Schedule before telling waiters, so a turn never mistakes the gap before the next pump for a stall.
279
- }).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
280
- this.busy.set(id, promise);
281
- };
282
- // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built. They start
283
- // in the order of the context they end at, a merge first at a tie, as Unii's do (#194): messages first left the merges waiting
284
- // through a backlog, so the views ran over budget and changed at every node built, and compactions read about 60% from the cache.
285
- const ready: Part[] = [];
286
- for (const i of this.unbuilt) { if (ready.length === this.jobs) break; ready.push({ l: 0, i }); }
287
- const at = (part: Part) => part.l ? end(part) : part.i;
288
- for (const part of [...ready, ...this.merges.values()].sort((a, b) => at(a) - at(b) || b.l - a.l)) {
289
- if (this.busy.size >= this.jobs) return;
290
- run(part);
291
- }
292
- // A later failure can have a later deadline than the timer installed by the first.
293
- const deadlines = [...this.retryAt.values()].filter(t => t > now);
294
- if (deadlines.length && !this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, Math.max(1, Math.min(...deadlines) - now));
295
- if (this.stalled) this.events.emit('change');
296
- }
297
- /** Nothing is being built, nothing more can start, and something failed: only a retry can make progress. */
298
- private get stalled() { return this.busy.size === 0 && !this.scheduled && this.retryAt.size > 0; }
299
- private async build(part: Part) {
300
- const source = part.l === 0 ? `${this.root[part.i].kind}: ${this.root[part.i].text}`
301
- : [0, 1].map(offset => this.text({ l: part.l - 1, i: 2 * part.i + offset })).join('\n');
302
- const mergeSource = part.l > 0 ? [0, 1].map(offset => flat(this.text({ l: part.l - 1, i: 2 * part.i + offset }))).join('\n') : source;
303
- const call = part.l === 0 ? this.callOf(part.i) : undefined;
304
- let text = source;
305
- if (bytes(source) > NODE) try {
306
- text = (await this.compress({ context: this.compactionView(part), source: cap(mergeSource, SOURCE), part,
307
- historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin),
308
- ...(call ? { call: { i: call.i, text: cap(`tool: ${call.text}`, 4000) } } : {}) }, this.controller.signal)).trim();
309
- if (!text) throw new Refused('Compactor returned an empty summary.');
310
- } catch (error) {
311
- if (!(error instanceof Refused) || this.stopped) throw error;
312
- text = cut(`${CLIPPED} ${flat(mergeSource)}`);
313
- this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${error.message} (kept its start instead)`);
314
- }
315
- if (this.stopped) return;
316
- const node = { ...part, text, size: lineBytes(text) };
317
- // A failed node write fails the build, which is retried.
318
- const written = this.enqueue(() => this.store.appendNode(node), error => { throw error; });
319
- if (written) await written;
320
- this.tree.set(key(part), node); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
321
- if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
322
- if (!this.retryAt.size) this.lastError = undefined;
323
- if (this.fit()) this.save();
324
- }
325
- /** The tool call a result answers: a run of calls is followed by their results in the same order. A result that doesn't
326
- * start with the call's tool name (an `Agent error`, an odd import) has none. */
327
- private callOf(i: number) {
328
- if (this.root[i].kind !== 'echo') return;
329
- let results = i;
330
- while (this.root[results - 1]?.kind === 'echo') results--;
331
- let calls = results;
332
- while (this.root[calls - 1]?.kind === 'tool') calls--;
333
- const call = calls + i - results < results ? this.root[calls + i - results] : undefined;
334
- return call && this.root[i].text.startsWith(`${call.text.split(' ', 1)[0]}:`) ? call : undefined;
335
- }
336
- /** The compactions' view up to the node. A line still being built shows as a placeholder instead of ending the view: an A/B on
337
- * real turns (#101) found that cutting there also hid the built lines after it, such as an echo's own tool call. */
338
- private compactionView(part: Part) {
339
- const boundary = part.l === 0 ? start(part) : end(part), lines: string[] = [];
340
- for (const p of this.compaction.parts) { if (end(p) > boundary) break; lines.push(`${start(p)}+${2 ** p.l}|${flat(this.text(p))}`); }
341
- return `${VIEW_OPEN}${lines.join('\n')}\n</chat>`;
342
- }
343
- private queueParent({ l, i }: Part) {
344
- const parent = { l: l + 1, i: Math.floor(i / 2) };
345
- if (!this.node(parent) && this.node({ l, i: i % 2 ? i - 1 : i + 1 })) this.merges.set(key(parent), parent);
346
- }
347
- /** A 'turn' waits until every part of the view is built, or until everything pending has failed: it then goes on with placeholders
348
- * for the missing lines, `lastError` says why, and the failed nodes are still retried in the background. 'tree' waits for every
349
- * node, through failures. As in the recipe, the view may run over budget until pending merges land.
350
- * 'ahead' (imports) waits until a new message's node would start at once. Due merges count too: messages go first, so a steady
351
- * stream of them would otherwise take every worker and the views could never merge. */
352
- async settle(signal?: AbortSignal, until: 'turn' | 'tree' | 'ahead' = 'turn'): Promise<void> {
353
- const done = () => until === 'tree' ? this.ready && this.busy.size === 0 && this.tree.size === this.expectedNodes()
354
- : until === 'ahead' ? this.unbuilt.size + this.merges.size < this.jobs
355
- : this.ready || this.stalled;
356
- if (done()) return;
357
- if (this.stopped || signal?.aborted) throw new Error('Memory wait cancelled.');
358
- await new Promise<void>((resolve, reject) => {
359
- const cleanup = () => { this.events.off('change', check); signal?.removeEventListener('abort', abort); };
360
- const abort = () => { cleanup(); reject(new Error('Memory wait cancelled.')); };
361
- const check = () => { if (this.stopped) abort(); else if (done()) { cleanup(); resolve(); } };
362
- this.events.on('change', check); signal?.addEventListener('abort', abort, { once: true }); check();
363
- });
364
- }
365
- private expectedNodes() {
366
- let count = 0;
367
- for (let n = this.root.length; n > 0; n = Math.floor(n / 2)) count += n;
368
- return count;
369
- }
370
- /** n = 1 gives the message whole, or, when it is longer than `limit` or `offset` is given, the page of up to `limit` characters from `offset`.
371
- * Paging a message that fits in one page is allowed, but the page says so. */
372
- zoom(id: number, n: number, offset?: number, limit?: number) {
373
- if (!Number.isSafeInteger(id) || id < 0 || !Number.isSafeInteger(n) || n < 1 || !Number.isInteger(Math.log2(n)) || id % n || id + n > this.root.length)
374
- throw new Error(`No line ${id}+${n}: n is a power of 2, id a multiple of n. The tree's roots: ${this.roots() || 'none, the chat is empty'}.`);
375
- if (n === 1) {
376
- const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
377
- if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
378
- if (offset === undefined && text.length <= page) return `${id}+1|${kind}: ${text}`;
379
- if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
380
- throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
381
- // Never split a surrogate pair: a page starts on its first half and ends after its second.
382
- const from = /[\udc00-\udfff]/.test(text[offset ?? 0] ?? '') ? (offset ?? 0) - 1 : offset ?? 0;
383
- let to = Math.min(text.length, from + page);
384
- // A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
385
- if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
386
- 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')}]` : '';
387
- return `${id}+1|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
388
- }
389
- if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
390
- const l = Math.log2(n) - 1, i = 2 * id / n;
391
- // A half not built yet shows as in the view, so the other half still comes back.
392
- return [0, 1].map(offset => {
393
- const part = { l, i: i + offset };
394
- return `${start(part)}+${2 ** l}|${flat(this.text(part))}`;
395
- }).join('\n');
396
- }
397
- /** The largest whole lines covering the chat, oldest first: one per power of two in the message count. */
398
- private roots() {
399
- const lines: string[] = [];
400
- for (let id = 0, n = 2 ** Math.floor(Math.log2(this.root.length || 1)); n >= 1; n /= 2)
401
- if (id + n <= this.root.length) { lines.push(`${id}+${n}`); id += n; }
402
- return lines.join(', ');
403
- }
404
- /** Original messages containing `text`, ignoring case, newest first, older than message `before`. Never summaries,
405
- * nor the logged zoom/search calls and results, which only copy memory. */
406
- search(text: string, before = this.root.length) {
407
- const needle = text.toLowerCase(), hits: Entry[] = [];
408
- for (let i = Math.min(before, this.root.length) - 1; i >= 0; i--) {
409
- const entry = this.root[i];
410
- if ((entry.kind === 'tool' || entry.kind === 'echo') && /^(zoom|search)[ :]/.test(entry.text)) continue;
411
- if (entry.text.toLowerCase().includes(needle)) hits.push(entry);
412
- }
413
- return hits;
414
- }
415
- date(id: number) {
416
- if (!Number.isSafeInteger(id) || !this.root[id]) throw new Error(`No message ${id}.`);
417
- return new Date(this.root[id].date).toString();
418
- }
419
- async close() {
420
- this.stopped = true; this.controller.abort(); clearTimeout(this.retryTimer);
421
- this.events.emit('change'); await Promise.allSettled(this.busy.values()); await this.writing;
13
+ /** A directory alone opens a FileStore there. */
14
+ constructor(source: string | { directory: string; store: Store; saved: Saved }, compress: Compressor,
15
+ warn: (s: string) => void = console.error, budget = VIEW, jobs = 8, retryMs = 10_000) {
16
+ const opened = typeof source === 'string' ? fileMemory(source, warn) : source;
17
+ super(opened, compress, warn, budget, jobs, retryMs);
18
+ this.directory = opened.directory;
422
19
  }
423
20
  }
21
+
22
+ function fileMemory(directory: string, warn: (s: string) => void) {
23
+ const store = new FileStore(directory, warn);
24
+ return { directory, store, saved: store.load() };
25
+ }