pi-optchat 0.8.0 → 0.8.2

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/index.ts CHANGED
@@ -6,22 +6,22 @@ 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 { atomicWrite, Memory } from './memory.ts';
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, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
- import { MASTER, VIEW_DOC } from './prompts.ts';
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, 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
- import { allowSearch, memoryTools, result, SEARCH_DOC, searchTool } from './tools.ts';
18
+ import { memoryTools, result, searchTool } from './tools.ts';
18
19
  import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
19
20
  import { exportBrowser } from './browser.ts';
20
21
  import { Inbox } from './inbox.ts';
21
22
  import { checkpoint } from './checkpoint.ts';
22
23
  import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
23
24
  import { chooseImport, showProgress } from './import/ui.ts';
24
- import { IMPORT_GUIDANCE } from './import/guidance.ts';
25
25
  import { UsageLedger } from './usage.ts';
26
26
  import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
27
27
  import { showAgentView } from './agent-view.ts';
@@ -42,7 +42,6 @@ async function printReply(text: string) {
42
42
  catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
43
43
  }
44
44
  }
45
- const CONTINUITY = '\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text; left out when very long), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.';
46
45
  const toggle = (prompt: string, line: string, on: boolean, after: string) => on === prompt.includes(line) ? prompt : on ? prompt.replace(after, after + line) : prompt.replace(line, '');
47
46
  /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
48
47
  const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
@@ -63,8 +62,11 @@ export default function optchat(pi: ExtensionAPI) {
63
62
  let prompt = '';
64
63
  let runStarted = false;
65
64
  let fault: string | undefined;
66
- /** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet. */
67
- let reports: { text: string; count?: number; batch?: string }[] = [];
65
+ /** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
66
+ * `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
67
+ let reports: { text: string; count?: number; batch?: string; steered?: boolean }[] = [];
68
+ type Report = (typeof reports)[number];
69
+ let idle = () => true;
68
70
  const receipts = new Map<AgentMessage, string>();
69
71
  let checkpoints = Promise.resolve();
70
72
  let importing = false;
@@ -108,7 +110,8 @@ export default function optchat(pi: ExtensionAPI) {
108
110
  catch { note = ' · import journal invalid'; }
109
111
  ctx.ui.setStatus('optchat', `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${note}`);
110
112
  };
111
- const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports)); };
113
+ // `steered` describes this process's Pi queue, so it is not journaled.
114
+ const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports, (key, value: unknown) => key === 'steered' ? undefined : value)); };
112
115
  const flush = () => {
113
116
  if (!active) return;
114
117
  while (logged < run.length) {
@@ -125,14 +128,17 @@ export default function optchat(pi: ExtensionAPI) {
125
128
  };
126
129
  // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
127
130
  // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
128
- const sendReport = (text: string, count?: number) => {
131
+ const sendReport = (report: Report) => {
132
+ const { text, count } = report;
133
+ report.steered = !idle();
129
134
  if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
130
135
  else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
131
136
  };
132
137
  const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
133
138
  if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
134
- reports.push({ text, count }); saveReports();
135
- if (!stopping) sendReport(text, count);
139
+ const report = { text, count };
140
+ reports.push(report); saveReports();
141
+ if (!stopping) sendReport(report);
136
142
  };
137
143
  const holdReports = (batch: string, texts: string[]) => {
138
144
  reports = [...reports.filter(r => r.batch !== batch), ...texts.length ? [{ text: texts.join('\n\n'), count: texts.length, batch }] : []];
@@ -152,7 +158,7 @@ export default function optchat(pi: ExtensionAPI) {
152
158
  await checkpoint(old.dir);
153
159
  } finally {
154
160
  await old.memory.close(); await old.unlock(); active = undefined;
155
- run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
161
+ run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = ''; idle = () => true;
156
162
  untitle?.(); untitle = undefined; title.clear(); working = false;
157
163
  }
158
164
  };
@@ -171,7 +177,7 @@ export default function optchat(pi: ExtensionAPI) {
171
177
  const openProfile = async (name: string, ctx: ExtensionContext) => {
172
178
  const dir = profilePath(name);
173
179
  if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
174
- const unlock = await lockProfile(dir, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`);
180
+ const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
175
181
  let openingMemory: Memory | undefined;
176
182
  try {
177
183
  const config = loadConfig(dir);
@@ -181,7 +187,7 @@ export default function optchat(pi: ExtensionAPI) {
181
187
  const pending = join(dir, 'pending-reports.json');
182
188
  const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
183
189
  if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
184
- const memory = new Memory(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
190
+ const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
185
191
  usage.compression(message, 'compactor', sessionId);
186
192
  status(ctx);
187
193
  }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
@@ -198,15 +204,19 @@ export default function optchat(pi: ExtensionAPI) {
198
204
  .filter(r => !loggedReports.has(reportReceipt(r.text)));
199
205
  atomicWrite(pending, JSON.stringify(reports));
200
206
  rememberProfile(name);
201
- active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
207
+ active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
202
208
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
203
209
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
204
210
  untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
205
211
  status(ctx);
206
212
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
207
213
  const queuedReports = [...reports];
208
- if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
209
- setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r.text, r.count); });
214
+ if (!pendingImport(dir)) {
215
+ const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
216
+ // Only an interactive Pi picks cut-off subagents up: a one-shot headless run would stop them again as it exits.
217
+ recovery = Promise.all([recover(children.recoverHandoffs(), 'Handoff recovery'), ...ctx.mode === 'tui' ? [recover(children.resumeCutOff(), 'Resuming subagents')] : []]).then(() => {});
218
+ }
219
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r); });
210
220
  } catch (error) {
211
221
  await closeWindows?.(); closeWindows = undefined;
212
222
  untitle?.(); untitle = undefined; title.clear();
@@ -312,13 +322,14 @@ export default function optchat(pi: ExtensionAPI) {
312
322
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
313
323
  prompt = event.systemPrompt;
314
324
  });
325
+ const DUPLICATE_REPORT = '(duplicate report, already delivered)';
315
326
  pi.on('message_end', async (event, ctx) => {
316
327
  if (!active || !runStarted) return;
317
328
  const bounded = boundedMessage(event.message);
318
329
  const message = asUser(bounded);
319
330
  // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
320
331
  if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
321
- try { await saveImages(active.memory.directory, message.content); }
332
+ try { await saveImages(active.memory.store, message.content); }
322
333
  catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
323
334
  }
324
335
  if (!active) return;
@@ -326,8 +337,14 @@ export default function optchat(pi: ExtensionAPI) {
326
337
  try {
327
338
  const text = textContent(message.content);
328
339
  // A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
329
- if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) receipts.set(message, reportReceipt(text));
330
- else {
340
+ if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) {
341
+ // A report sent again after a lost steer can still arrive twice (Pi kept the old steer after an abort): drop the extra copy.
342
+ // Copies are counted, so a later report that happens to repeat an earlier text still arrives.
343
+ const receipt = reportReceipt(text), copies = run.slice(logged).filter(m => receipts.get(m) === receipt).length;
344
+ if (reports.filter(r => !r.batch && r.text === text).length <= copies && (copies > 0 || active.memory.root.some(e => e.receipt === receipt)))
345
+ return { message: bounded.role === 'custom' ? { ...bounded, content: DUPLICATE_REPORT, display: false } : { ...message, content: DUPLICATE_REPORT } };
346
+ receipts.set(message, receipt);
347
+ } else {
331
348
  // The inbox journaled the typed input: match without image references or Pi's image notes.
332
349
  const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
333
350
  let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
@@ -363,7 +380,8 @@ export default function optchat(pi: ExtensionAPI) {
363
380
  view = a.memory.render(); // Capture old history before logging the new input.
364
381
  flush();
365
382
  }
366
- return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous) };
383
+ // Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached.
384
+ return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous, atWork(a.children.working)) };
367
385
  } catch (error) {
368
386
  // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
369
387
  ctx.abort();
@@ -386,6 +404,8 @@ export default function optchat(pi: ExtensionAPI) {
386
404
  pi.on('agent_settled', async (_event, ctx) => {
387
405
  collectUsage(ctx);
388
406
  try { flush(); active?.inbox.dropReturned(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
407
+ // A report steered into this run that never reached it (Esc or a dequeue cleared Pi's queue) is sent again, now as its own turn.
408
+ if (active && !fault && !stopping) for (const r of reports) if (r.steered && !r.batch) sendReport(r);
389
409
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
390
410
  runStarted = false; status(ctx);
391
411
  working = false; showTitle(ctx);
@@ -397,7 +417,8 @@ export default function optchat(pi: ExtensionAPI) {
397
417
  });
398
418
  registerConnectedRenderer(pi);
399
419
  registerReportRenderer(pi);
400
- for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
420
+ const [zoom, date] = memoryTools(() => required().memory, id => required().children.transcript(id));
421
+ pi.registerTool(zoom); pi.registerTool(date);
401
422
  pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
402
423
  // The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
403
424
  // Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
@@ -485,7 +506,7 @@ export default function optchat(pi: ExtensionAPI) {
485
506
  const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
486
507
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
487
508
  }, () => a.config.summaryAcceptBytes);
488
- const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, progress), signal);
509
+ const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal);
489
510
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
490
511
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
491
512
  })();
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;
@@ -18,8 +17,6 @@ export interface Compression { context: string; source: string; part: Part; hist
18
17
  export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
19
18
  const key = ({ l, i }: Part) => l * 2 ** 40 + i;
20
19
  const UNBUILT = '(not summarized yet: zoom it)';
21
- /** A message's node starts once fewer than this many lines before it are unbuilt (recipe §4). */
22
- const AHEAD = 8;
23
20
  export const start = ({ l, i }: Part) => i * 2 ** l;
24
21
  export const end = (part: Part) => start(part) + 2 ** part.l;
25
22
  export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
@@ -36,20 +33,6 @@ export function cap(text: string, limit = CAP) {
36
33
  const tail = text.slice(/[\udc00-\udfff]/.test(text[text.length - half]) ? text.length - half + 1 : text.length - half);
37
34
  return head + notice(text.length - head.length - tail.length) + tail;
38
35
  }
39
- export function localDay(date = new Date()) {
40
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
41
- }
42
- export function atomicWrite(file: string, text: string | Uint8Array) {
43
- mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
44
- const temporary = `${file}.${process.pid}.tmp`;
45
- // 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.
46
- const fd = openSync(temporary, 'w+', 0o600);
47
- try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
48
- renameSync(temporary, file);
49
- if (process.platform === 'win32') return;
50
- const parent = openSync(resolve(file, '..'), 'r');
51
- try { fsyncSync(parent); } finally { closeSync(parent); }
52
- }
53
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
54
37
  * long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
55
38
  export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
@@ -96,33 +79,6 @@ class Sawtooth {
96
79
  return this.parts.length < lines;
97
80
  }
98
81
  }
99
- const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
100
- /** 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. */
101
- export function appendJson(file: string, value: unknown, size?: number) {
102
- const fd = openSync(file, 'a+', 0o600);
103
- try {
104
- const length = fstatSync(fd).size, last = Buffer.alloc(1);
105
- if (size !== undefined && length !== size) throw otherWriter(file);
106
- const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
107
- const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
108
- if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
109
- fsyncSync(fd);
110
- return fstatSync(fd).size;
111
- } finally { closeSync(fd); }
112
- }
113
- function records(dir: string, warn: (s: string) => void): unknown[] {
114
- if (!existsSync(dir)) return [];
115
- const result: unknown[] = [];
116
- for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl')).sort()) {
117
- const file = join(dir, name);
118
- for (const [index, line] of readFileSync(file, 'utf8').split('\n').entries()) {
119
- if (!line.trim()) continue;
120
- try { result.push(JSON.parse(line)); }
121
- catch { warn(`Skipped damaged JSON at ${file}:${index + 1}`); }
122
- }
123
- }
124
- return result;
125
- }
126
82
  function object(value: unknown): value is Record<string, unknown> {
127
83
  return typeof value === 'object' && value !== null;
128
84
  }
@@ -148,7 +104,6 @@ export class Memory {
148
104
  private readonly busy = new Map<number, Promise<void>>();
149
105
  private readonly retryAt = new Map<number, number>();
150
106
  private readonly reported = new Set<number>();
151
- private readonly lastSeenBytes = new Map<string, number>();
152
107
  /** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
153
108
  private readonly unbuilt = new Set<number>();
154
109
  private readonly merges = new Map<number, Part>();
@@ -159,20 +114,34 @@ export class Memory {
159
114
  private stopped = false;
160
115
  private readonly chat: Sawtooth;
161
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;
162
123
  lastError?: string;
163
124
 
164
- constructor(readonly directory: string, private readonly compress: Compressor,
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,
165
133
  private readonly warn: (s: string) => void = console.error,
166
134
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
167
135
  this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
168
136
  this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
169
- for (const sub of ['main', 'tree']) mkdirSync(join(directory, sub), { recursive: true, mode: 0o700 });
170
- const main = join(directory, 'main'), log = records(main, warn);
171
- for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
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;
172
141
  const entries = log.every(isEntry) ? log.sort((a, b) => a.i - b.i) : undefined;
173
142
  if (!entries || entries.some((entry, i) => entry.i !== i)) throw new Error('Invalid/noncontiguous OptChat log; refusing to change it.');
174
143
  for (const value of entries) this.root.push({ ...value, size: bytes(`${value.kind}: ${value.text}`) });
175
- for (const value of records(join(directory, 'tree'), warn)) {
144
+ for (const value of saved.tree) {
176
145
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
177
146
  throw new Error('Invalid OptChat summary record.');
178
147
  this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
@@ -180,34 +149,43 @@ export class Memory {
180
149
  for (const node of this.tree.values()) this.queueParent(node);
181
150
  for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
182
151
  // Refolding the log gives a different view than the live one, and every cache entry would miss, so the view is saved.
183
- const loaded = this.load();
152
+ const loaded = this.load(saved.view);
184
153
  for (const part of this.view) this.compaction.push(part);
185
154
  this.compaction.fit(loaded, true);
186
155
  for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
187
156
  this.fit(); this.save(); this.schedule();
188
157
  }
189
- private checkLog(next: string) {
190
- const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
191
- if (names.length !== this.lastSeenBytes.size || names.some(n => statSync(join(main, n)).size !== this.lastSeenBytes.get(join(main, n)))) throw otherWriter(next);
192
- }
193
158
  append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) {
194
159
  if (this.stopped) throw new Error('Memory is closed.');
160
+ if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`);
195
161
  const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) };
196
- const file = join(this.directory, 'main', `${localDay()}.jsonl`);
197
- if (!this.lastSeenBytes.has(file)) this.checkLog(file);
198
- this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
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
+ });
199
166
  this.root.push(entry); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
200
167
  return entry;
201
168
  }
202
169
  node(part: Part) { return this.tree.get(key(part)); }
203
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
+ }
204
183
  /** Takes the saved view if it still tiles the log from the start, and returns how many messages it covers. */
205
- private load() {
206
- const file = join(this.directory, 'view.json');
207
- if (!existsSync(file)) return 0;
208
- 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; };
209
187
  let saved: unknown;
210
- try { saved = JSON.parse(readFileSync(file, 'utf8')); } catch { return rebuild(); }
188
+ try { saved = JSON.parse(view); } catch { return rebuild(); }
211
189
  if (!Array.isArray(saved)) return rebuild();
212
190
  const parts: Part[] = [];
213
191
  let covered = 0;
@@ -224,8 +202,9 @@ export class Memory {
224
202
  /** Saved when it merges: a view that only grew is the saved one plus a line per later message, as `load` replays it.
225
203
  * The log stays authoritative, so a failed save only warns. */
226
204
  private save() {
227
- try { atomicWrite(join(this.directory, 'view.json'), JSON.stringify(this.view.map(p => [p.l, p.i]))); }
228
- catch (error) { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); }
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); }
229
208
  }
230
209
  private push(i: number) {
231
210
  const part = { l: 0, i };
@@ -278,10 +257,10 @@ export class Memory {
278
257
  }).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
279
258
  this.busy.set(id, promise);
280
259
  };
281
- // A message's node starts once fewer than AHEAD lines before it are unbuilt; a merge, once both halves are built.
260
+ // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built.
282
261
  let ahead = 0;
283
262
  for (const i of this.unbuilt) {
284
- if (ahead++ === AHEAD) break;
263
+ if (ahead++ === this.jobs) break;
285
264
  if (this.busy.size >= this.jobs) return;
286
265
  run({ l: 0, i });
287
266
  }
@@ -305,7 +284,9 @@ export class Memory {
305
284
  if (this.stopped) return;
306
285
  if (!text) throw new Error('Compactor returned an empty summary.');
307
286
  const node = { ...part, text, size: lineBytes(text) };
308
- appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
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;
309
290
  this.tree.set(key(part), node); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
310
291
  if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
311
292
  if (!this.retryAt.size) this.lastError = undefined;
@@ -329,7 +310,7 @@ export class Memory {
329
310
  * stream of them would otherwise take every worker and the views could never merge. */
330
311
  async settle(signal?: AbortSignal, until: 'turn' | 'tree' | 'ahead' = 'turn'): Promise<void> {
331
312
  const done = () => until === 'tree' ? this.ready && this.busy.size === 0 && this.tree.size === this.expectedNodes()
332
- : until === 'ahead' ? this.unbuilt.size + this.merges.size < AHEAD
313
+ : until === 'ahead' ? this.unbuilt.size + this.merges.size < this.jobs
333
314
  : this.ready || this.stalled;
334
315
  if (done()) return;
335
316
  if (this.stopped || signal?.aborted) throw new Error('Memory wait cancelled.');
@@ -389,6 +370,6 @@ export class Memory {
389
370
  }
390
371
  async close() {
391
372
  this.stopped = true; this.controller.abort(); clearTimeout(this.retryTimer);
392
- this.events.emit('change'); await Promise.allSettled(this.busy.values());
373
+ this.events.emit('change'); await Promise.allSettled(this.busy.values()); await this.writing;
393
374
  }
394
375
  }
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 './memory.ts';
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
+ }