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/README.md +10 -5
- package/package.json +1 -1
- package/src/agents.ts +63 -15
- package/src/browser.ts +1 -1
- package/src/cache.ts +16 -1
- package/src/checkpoint.ts +1 -1
- package/src/compactor.ts +4 -15
- package/src/handoff.ts +3 -3
- package/src/images.ts +9 -19
- package/src/import/job.ts +5 -4
- package/src/inbox.ts +1 -1
- package/src/index.ts +46 -25
- package/src/memory.ts +52 -71
- package/src/profiles.ts +16 -1
- package/src/prompts.ts +193 -4
- package/src/report-message.ts +2 -1
- package/src/runs.ts +8 -2
- package/src/settings-page.ts +1 -1
- package/src/settings.ts +9 -2
- package/src/store.ts +125 -0
- package/src/tools.ts +30 -14
- package/src/transcript.ts +41 -4
- package/src/usage.ts +1 -1
- package/src/import/guidance.ts +0 -1
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 {
|
|
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 { 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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 =
|
|
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))
|
|
209
|
-
|
|
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.
|
|
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))
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
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++ ===
|
|
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
|
-
|
|
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 <
|
|
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 './
|
|
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
|
+
}
|