pi-optchat 0.7.2 → 0.8.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/memory.ts CHANGED
@@ -1,16 +1,20 @@
1
- import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
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
3
  import { EventEmitter } from 'node:events';
4
4
 
5
5
  export const NODE = 512;
6
6
  export const VIEW = 128_000;
7
7
  export const CAP = 30_000;
8
- export type Kind = 'user' | 'talk' | 'tool' | 'echo' | 'note';
9
- export interface Origin { source: 'claude' | 'claude-memory' | 'codex' | 'chatgpt'; conversation: string; message: string; title: string; project?: string }
8
+ /** The most characters one zoom into a message returns, so a page stays under the tool output CAP instead of losing its middle. */
9
+ export const PAGE = 25_000;
10
+ export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const;
11
+ export type Kind = typeof KINDS[number];
12
+ export interface Origin { source: 'claude' | 'claude-memory' | 'codex' | 'pi' | 'chatgpt'; conversation: string; message: string; title: string; project?: string }
10
13
  export interface Entry { i: number; kind: Kind; text: string; size: number; date: string; receipt?: string; origin?: Origin }
11
14
  export interface Part { l: number; i: number }
12
15
  export interface Summary extends Part { text: string; size: number }
13
- export interface Compression { context: string; source: string; merge: boolean; historical?: boolean }
16
+ /** `part` is the node to build: a message's leaf, or the merge of its two halves. */
17
+ export interface Compression { context: string; source: string; part: Part; historical?: boolean }
14
18
  export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
15
19
  const key = ({ l, i }: Part) => l * 2 ** 40 + i;
16
20
  const UNBUILT = '(not summarized yet: zoom it)';
@@ -33,6 +37,63 @@ export function cap(text: string, limit = CAP) {
33
37
  export function localDay(date = new Date()) {
34
38
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
35
39
  }
40
+ export function atomicWrite(file: string, text: string | Uint8Array) {
41
+ mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
42
+ const temporary = `${file}.${process.pid}.tmp`;
43
+ // One read-write handle: Windows refuses fsync on a read-only one, and it cannot open a directory at all, so there the file flush is the whole guarantee.
44
+ const fd = openSync(temporary, 'w+', 0o600);
45
+ try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
46
+ renameSync(temporary, file);
47
+ if (process.platform === 'win32') return;
48
+ const parent = openSync(resolve(file, '..'), 'r');
49
+ try { fsyncSync(parent); } finally { closeSync(parent); }
50
+ }
51
+ /** The index of the view's most due pair of sibling lines whose parent is built, or -1 (recipe §3.2). A pair is as due as
52
+ * long ago it ended, in its own line size; equal pairs go oldest first. This is the order of Taelin's rollback push. */
53
+ export function mostDue(view: readonly Part[], total: number, built: (part: Part) => boolean) {
54
+ let best = -1, due = -Infinity;
55
+ for (let j = 0; j + 1 < view.length; j++) {
56
+ const a = view[j], b = view[j + 1];
57
+ if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
58
+ const age = (total - (end(b) - 1)) / 2 ** a.l;
59
+ if (age > due && built({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
60
+ }
61
+ return best;
62
+ }
63
+ /** 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
64
+ * forced, merges its most due pairs in one batch down to `low`, merging what it can at each change until then. */
65
+ class Sawtooth {
66
+ readonly parts: Part[] = [];
67
+ private measured = 0;
68
+ private merging = false;
69
+ constructor(private readonly high: number, private readonly low: number, private readonly node: (part: Part) => Summary | undefined) {}
70
+ private lineSize(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
71
+ get size() { return this.measured; }
72
+ push(part: Part) { this.parts.push(part); this.measured += this.lineSize(part); }
73
+ /** The line covering message `at`. The lines tile the log in order, so a binary search finds it. */
74
+ covering(at: number): Part | undefined {
75
+ for (let lo = 0, hi = this.parts.length - 1; lo <= hi;) {
76
+ const mid = (lo + hi) >> 1, p = this.parts[mid];
77
+ if (end(p) <= at) lo = mid + 1;
78
+ else if (start(p) > at) hi = mid - 1;
79
+ else return p;
80
+ }
81
+ }
82
+ /** A message got its node. Its line is usually still shown, but after a damaged tree file a saved parent can already hide it. */
83
+ built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.lineSize({ l: 0, i }) - UNBUILT_BYTES; }
84
+ /** Returns whether any lines merged. */
85
+ fit(total: number, force = false) {
86
+ const lines = this.parts.length;
87
+ if (force || this.measured > this.high) this.merging = true;
88
+ for (let best: number; this.merging && this.measured > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
89
+ const a = this.parts[best], b = this.parts[best + 1], parent = { l: a.l + 1, i: a.i / 2 };
90
+ this.measured += this.lineSize(parent) - this.lineSize(a) - this.lineSize(b);
91
+ this.parts.splice(best, 2, parent);
92
+ }
93
+ if (this.measured <= this.low) this.merging = false;
94
+ return this.parts.length < lines;
95
+ }
96
+ }
36
97
  const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
37
98
  /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
38
99
  export function appendJson(file: string, value: unknown, size?: number) {
@@ -65,7 +126,7 @@ function object(value: unknown): value is Record<string, unknown> {
65
126
  }
66
127
  export function isEntry(value: unknown): value is Entry {
67
128
  return object(value) && Number.isSafeInteger(value.i) && typeof value.kind === 'string'
68
- && ['user', 'talk', 'tool', 'echo', 'note'].includes(value.kind)
129
+ && KINDS.some(kind => kind === value.kind)
69
130
  && typeof value.text === 'string' && typeof value.date === 'string';
70
131
  }
71
132
  function isSummary(value: unknown): value is Summary {
@@ -73,31 +134,37 @@ function isSummary(value: unknown): value is Summary {
73
134
  && typeof value.text === 'string';
74
135
  }
75
136
 
76
- /** The log is authoritative. The tree and monotonically coarsening view follow recipe §§2–6. */
137
+ /** The log is authoritative. The tree and the view follow the recipe: the view grows a line per message and, once it passes
138
+ * `budget`, merges its most due pairs in one batch down to half of it, so between batches it only grows at its end.
139
+ * Compactions get their own view (recipe §4): the chat's merged further, to between an eighth and a quarter of the budget,
140
+ * and again whenever the chat's merges, so parallel compactions read it from the cache. */
77
141
  export class Memory {
78
142
  readonly root: Entry[] = [];
79
143
  readonly tree = new Map<number, Summary>();
80
- readonly view: Part[] = [];
81
144
  private readonly events = new EventEmitter();
82
145
  private readonly controller = new AbortController();
83
146
  private readonly busy = new Map<number, Promise<void>>();
84
147
  private readonly retryAt = new Map<number, number>();
85
148
  private readonly reported = new Set<number>();
86
149
  private readonly lastSeenBytes = new Map<string, number>();
87
- private viewBytes = 0;
88
- private leaves = 0;
89
- /** Per level, every node below this index is built. */
90
- private readonly low: number[] = [];
150
+ /** Messages without their node, and merges whose halves are built, oldest first, so the scheduler never scans the tree for work (recipe §4). */
151
+ private readonly unbuilt = new Set<number>();
152
+ private readonly merges = new Map<number, Part>();
91
153
  private retryTimer?: ReturnType<typeof setTimeout>;
92
154
  /** The most summaries owed at once since none were last owed. */
93
155
  private peak = 0;
94
156
  private scheduled = false;
95
157
  private stopped = false;
158
+ private readonly chat: Sawtooth;
159
+ private readonly compaction: Sawtooth;
96
160
  lastError?: string;
97
161
 
162
+ /** `jobs`: summaries built at once, and how many unbuilt lines a new message's node waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */
98
163
  constructor(readonly directory: string, private readonly compress: Compressor,
99
164
  private readonly warn: (s: string) => void = console.error,
100
165
  readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
166
+ this.chat = new Sawtooth(budget, budget / 2, part => this.node(part));
167
+ this.compaction = new Sawtooth(budget / 4, budget / 8, part => this.node(part));
101
168
  for (const sub of ['main', 'tree']) mkdirSync(join(directory, sub), { recursive: true, mode: 0o700 });
102
169
  const main = join(directory, 'main'), log = records(main, warn);
103
170
  for (const name of readdirSync(main).filter(n => n.endsWith('.jsonl'))) this.lastSeenBytes.set(join(main, name), statSync(join(main, name)).size);
@@ -107,12 +174,16 @@ export class Memory {
107
174
  for (const value of records(join(directory, 'tree'), warn)) {
108
175
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
109
176
  throw new Error('Invalid OptChat summary record.');
110
- if (value.l === 0 && !this.tree.has(key(value))) this.leaves++;
111
177
  this.tree.set(key(value), { ...value, size: lineBytes(value.text) });
112
178
  }
113
- // Fold history in order; do not retile the entire log on each turn.
114
- for (let i = 0; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
115
- this.schedule();
179
+ for (const node of this.tree.values()) this.queueParent(node);
180
+ for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
181
+ // Refolding the log gives a different view than the live one, and every cache entry would miss, so the view is saved.
182
+ const loaded = this.load();
183
+ for (const part of this.view) this.compaction.push(part);
184
+ this.compaction.fit(loaded, true);
185
+ for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
186
+ this.fit(); this.save(); this.schedule();
116
187
  }
117
188
  private checkLog(next: string) {
118
189
  const main = dirname(next), names = readdirSync(main).filter(n => n.endsWith('.jsonl'));
@@ -124,18 +195,48 @@ export class Memory {
124
195
  const file = join(this.directory, 'main', `${localDay()}.jsonl`);
125
196
  if (!this.lastSeenBytes.has(file)) this.checkLog(file);
126
197
  this.lastSeenBytes.set(file, appendJson(file, entry, this.lastSeenBytes.get(file) ?? 0));
127
- this.root.push(entry); this.push(entry.i); this.fit(); this.schedule();
198
+ this.root.push(entry); this.push(entry.i); if (this.fit()) this.save(); this.schedule();
128
199
  return entry;
129
200
  }
130
201
  node(part: Part) { return this.tree.get(key(part)); }
131
202
  private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
132
- private partBytes(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
133
- private push(i: number) { const part = { l: 0, i }; this.view.push(part); this.viewBytes += this.partBytes(part); }
203
+ /** Takes the saved view if it still tiles the log from the start, and returns how many messages it covers. */
204
+ private load() {
205
+ const file = join(this.directory, 'view.json');
206
+ if (!existsSync(file)) return 0;
207
+ const rebuild = () => { this.warn(`Rebuilt the memory view: ${file} does not match the log.`); return 0; };
208
+ let saved: unknown;
209
+ try { saved = JSON.parse(readFileSync(file, 'utf8')); } catch { return rebuild(); }
210
+ if (!Array.isArray(saved)) return rebuild();
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 rebuild();
218
+ parts.push(part); covered = end(part);
219
+ }
220
+ for (const part of parts) this.chat.push(part);
221
+ return covered;
222
+ }
223
+ /** Saved when it merges: a view that only grew is the saved one plus a line per later message, as `load` replays it.
224
+ * The log stays authoritative, so a failed save only warns. */
225
+ private save() {
226
+ try { atomicWrite(join(this.directory, 'view.json'), JSON.stringify(this.view.map(p => [p.l, p.i]))); }
227
+ catch (error) { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); }
228
+ }
229
+ private push(i: number) {
230
+ const part = { l: 0, i };
231
+ this.chat.push(part); this.compaction.push(part);
232
+ if (!this.node(part)) this.unbuilt.add(i);
233
+ }
234
+ get view(): readonly Part[] { return this.chat.parts; }
134
235
  render() { return `${VIEW_OPEN}${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
135
236
  get ready() { return this.view.every(p => this.node(p)); }
136
- get pending() { return this.root.length - this.leaves; }
237
+ get pending() { return this.unbuilt.size; }
137
238
  get active() { return this.busy.size; }
138
- get size() { return this.viewBytes; }
239
+ get size() { return this.chat.size; }
139
240
  /** Summaries built out of the backlog since it was last empty, and when the next failed one is retried. */
140
241
  progress(now = Date.now()) {
141
242
  const due = Math.min(...[...this.retryAt.values()].filter(t => t > now));
@@ -143,34 +244,16 @@ export class Memory {
143
244
  }
144
245
  private owed() { return this.expectedNodes() - this.tree.size; }
145
246
  onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
146
- /** The view line covering message `at`. The view tiles the log in order, so a binary search finds it. */
147
- covering(at: number): Part | undefined {
148
- for (let lo = 0, hi = this.view.length - 1; lo <= hi;) {
149
- const mid = (lo + hi) >> 1, p = this.view[mid];
150
- if (end(p) <= at) lo = mid + 1;
151
- else if (start(p) > at) hi = mid - 1;
152
- else return p;
153
- }
154
- }
155
- private visible(part: Part) { const p = this.covering(start(part)); return p?.l === part.l && p.i === part.i; }
247
+ /** The view line covering message `at`. */
248
+ covering(at: number) { return this.chat.covering(at); }
249
+ /** Returns whether the chat's view merged. */
156
250
  private fit(total = this.root.length) {
157
- while (this.viewBytes > this.budget) {
158
- let best = -1; let due = -Infinity;
159
- for (let j = 0; j + 1 < this.view.length; j++) {
160
- const a = this.view[j], b = this.view[j + 1];
161
- if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
162
- const age = (total - start(a)) / 2 ** (a.l + 2);
163
- if (age > due && this.node({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
164
- }
165
- if (best < 0) break;
166
- const a = this.view[best], b = this.view[best + 1];
167
- const parent = { l: a.l + 1, i: a.i / 2 };
168
- this.viewBytes += this.partBytes(parent) - this.partBytes(a) - this.partBytes(b);
169
- this.view.splice(best, 2, parent);
170
- }
251
+ const merged = this.chat.fit(total);
252
+ this.compaction.fit(total, merged);
171
253
  const owed = this.owed();
172
254
  this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
173
255
  this.events.emit('change');
256
+ return merged;
174
257
  }
175
258
  private schedule() {
176
259
  if (this.scheduled || this.stopped) return;
@@ -181,54 +264,72 @@ export class Memory {
181
264
  if (this.stopped) return;
182
265
  // One clock reading: skipping a part and arming its retry timer must agree on what is due.
183
266
  const now = Date.now();
184
- const total = this.root.length;
185
- const first = this.view.find(p => !this.node(p));
186
- const boundary = first ? start(first) : total;
187
- for (let l = 0; 2 ** l <= total; l++) {
188
- let low = this.low[l] ?? 0;
189
- while (this.node({ l, i: low })) low++;
190
- this.low[l] = low;
191
- for (let i = low; (i + 1) * 2 ** l <= total; i++) {
192
- if (this.busy.size >= this.jobs) return;
193
- const part = { l, i }, id = key(part);
194
- if ((l === 0 ? i : end(part)) > boundary) break;
195
- if (this.node(part) || this.busy.has(id) || (this.retryAt.get(id) ?? 0) > now) continue;
196
- if (l && (!this.node({ l: l - 1, i: 2 * i }) || !this.node({ l: l - 1, i: 2 * i + 1 }))) continue;
197
- const promise = this.build(part).catch(error => {
198
- if (this.stopped) return;
199
- this.lastError = error instanceof Error ? error.message : String(error);
200
- if (!this.reported.has(id)) { this.reported.add(id); this.warn(`Compactor ${start(part)}+${2 ** l}: ${this.lastError}`); }
201
- this.retryAt.set(id, Date.now() + this.retryMs);
202
- if (!this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, this.retryMs);
203
- }).finally(() => { this.busy.delete(id); this.events.emit('change'); this.schedule(); });
204
- this.busy.set(id, promise);
205
- }
267
+ const run = (part: Part) => {
268
+ const id = key(part);
269
+ if (this.busy.has(id) || (this.retryAt.get(id) ?? 0) > now) return;
270
+ const promise = this.build(part).catch(error => {
271
+ if (this.stopped) return;
272
+ this.lastError = error instanceof Error ? error.message : String(error);
273
+ if (!this.reported.has(id)) { this.reported.add(id); this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${this.lastError}`); }
274
+ this.retryAt.set(id, Date.now() + this.retryMs);
275
+ if (!this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, this.retryMs);
276
+ // Schedule before telling waiters, so a turn never mistakes the gap before the next pump for a stall.
277
+ }).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
278
+ this.busy.set(id, promise);
279
+ };
280
+ // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built.
281
+ let ahead = 0;
282
+ for (const i of this.unbuilt) {
283
+ if (ahead++ === this.jobs) break;
284
+ if (this.busy.size >= this.jobs) return;
285
+ run({ l: 0, i });
286
+ }
287
+ for (const part of this.merges.values()) {
288
+ if (this.busy.size >= this.jobs) return;
289
+ run(part);
206
290
  }
207
291
  // A later failure can have a later deadline than the timer installed by the first.
208
292
  const deadlines = [...this.retryAt.values()].filter(t => t > now);
209
293
  if (deadlines.length && !this.retryTimer) this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.schedule(); }, Math.max(1, Math.min(...deadlines) - now));
294
+ if (this.stalled) this.events.emit('change');
210
295
  }
296
+ /** Nothing is being built, nothing more can start, and something failed: only a retry can make progress. */
297
+ private get stalled() { return this.busy.size === 0 && !this.scheduled && this.retryAt.size > 0; }
211
298
  private async build(part: Part) {
212
299
  const source = part.l === 0 ? `${this.root[part.i].kind}: ${this.root[part.i].text}`
213
300
  : [0, 1].map(offset => this.text({ l: part.l - 1, i: 2 * part.i + offset })).join('\n');
214
- const boundary = part.l === 0 ? start(part) : end(part);
215
- const context = `<chat>\n${this.view.filter(p => end(p) <= boundary).map(p => flat(this.text(p))).join('\n')}\n</chat>`;
216
301
  const mergeSource = part.l > 0 ? [0, 1].map(offset => flat(this.text({ l: part.l - 1, i: 2 * part.i + offset }))).join('\n') : source;
217
- const text = bytes(source) <= NODE ? source : (await this.compress({ context, source: mergeSource, merge: part.l > 0,
302
+ const text = bytes(source) <= NODE ? source : (await this.compress({ context: this.compactionView(part), source: mergeSource, part,
218
303
  historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin) }, this.controller.signal)).trim();
219
304
  if (this.stopped) return;
220
305
  if (!text) throw new Error('Compactor returned an empty summary.');
221
306
  const node = { ...part, text, size: lineBytes(text) };
222
307
  appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
223
- this.tree.set(key(part), node); this.retryAt.delete(key(part));
224
- // A leaf is usually still in the view when built, but after a damaged tree file a saved parent can already hide it.
225
- if (part.l === 0) { this.leaves++; if (this.visible(part)) this.viewBytes += node.size - UNBUILT_BYTES; }
308
+ this.tree.set(key(part), node); this.retryAt.delete(key(part)); this.merges.delete(key(part)); this.queueParent(part);
309
+ if (!part.l) { this.unbuilt.delete(part.i); this.chat.built(part.i); this.compaction.built(part.i); }
226
310
  if (!this.retryAt.size) this.lastError = undefined;
227
- this.fit();
311
+ if (this.fit()) this.save();
228
312
  }
229
- /** Waits until every part of the view is built. As in the recipe, the view may run over budget until pending merges land. */
230
- async settle(signal?: AbortSignal, all = false): Promise<void> {
231
- const done = () => this.ready && (!all || (this.busy.size === 0 && this.tree.size === this.expectedNodes()));
313
+ /** 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
314
+ * real turns (#101) found that cutting there also hid the built lines after it, such as an echo's own tool call. */
315
+ private compactionView(part: Part) {
316
+ const boundary = part.l === 0 ? start(part) : end(part), lines: string[] = [];
317
+ for (const p of this.compaction.parts) { if (end(p) > boundary) break; lines.push(`${start(p)}+${2 ** p.l}|${flat(this.text(p))}`); }
318
+ return `${VIEW_OPEN}${lines.join('\n')}\n</chat>`;
319
+ }
320
+ private queueParent({ l, i }: Part) {
321
+ const parent = { l: l + 1, i: Math.floor(i / 2) };
322
+ if (!this.node(parent) && this.node({ l, i: i % 2 ? i - 1 : i + 1 })) this.merges.set(key(parent), parent);
323
+ }
324
+ /** A 'turn' waits until every part of the view is built, or until everything pending has failed: it then goes on with placeholders
325
+ * for the missing lines, `lastError` says why, and the failed nodes are still retried in the background. 'tree' waits for every
326
+ * node, through failures. As in the recipe, the view may run over budget until pending merges land.
327
+ * 'ahead' (imports) waits until a new message's node would start at once. Due merges count too: messages go first, so a steady
328
+ * stream of them would otherwise take every worker and the views could never merge. */
329
+ async settle(signal?: AbortSignal, until: 'turn' | 'tree' | 'ahead' = 'turn'): Promise<void> {
330
+ const done = () => until === 'tree' ? this.ready && this.busy.size === 0 && this.tree.size === this.expectedNodes()
331
+ : until === 'ahead' ? this.unbuilt.size + this.merges.size < this.jobs
332
+ : this.ready || this.stalled;
232
333
  if (done()) return;
233
334
  if (this.stopped || signal?.aborted) throw new Error('Memory wait cancelled.');
234
335
  await new Promise<void>((resolve, reject) => {
@@ -243,10 +344,26 @@ export class Memory {
243
344
  for (let n = this.root.length; n > 0; n = Math.floor(n / 2)) count += n;
244
345
  return count;
245
346
  }
246
- zoom(id: number, n: number) {
347
+ /** 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`.
348
+ * Paging a message that fits in one page is allowed, but the page says so. */
349
+ zoom(id: number, n: number, offset?: number, limit?: number) {
247
350
  if (!Number.isSafeInteger(id) || id < 0 || !Number.isSafeInteger(n) || n < 1 || !Number.isInteger(Math.log2(n)) || id % n || id + n > this.root.length)
248
351
  throw new Error(`No line ${id}+${n}.`);
249
- if (n === 1) { const entry = this.root[id]; return `${id}+0|${entry.kind}: ${entry.text}`; }
352
+ if (n === 1) {
353
+ const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
354
+ if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
355
+ if (offset === undefined && text.length <= page) return `${id}+0|${kind}: ${text}`;
356
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
357
+ throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
358
+ // Never split a surrogate pair: a page starts on its first half and ends after its second.
359
+ const from = /[\udc00-\udfff]/.test(text[offset ?? 0] ?? '') ? (offset ?? 0) - 1 : offset ?? 0;
360
+ let to = Math.min(text.length, from + page);
361
+ // A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
362
+ if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
363
+ const hint = text.length <= PAGE ? `\n[note: this message is only ${text.length.toLocaleString('en-US')} characters and fits in one zoom; offset/limit are for messages over ${PAGE.toLocaleString('en-US')}]` : '';
364
+ return `${id}+0|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
365
+ }
366
+ if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
250
367
  const l = Math.log2(n) - 1, i = 2 * id / n;
251
368
  return [0, 1].map(offset => {
252
369
  const part = { l, i: i + offset }, node = this.node(part);
package/src/profiles.ts CHANGED
@@ -1,11 +1,16 @@
1
- import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync, type Stats } from 'node:fs';
2
3
  import { createConnection, createServer } from 'node:net';
3
4
  import { homedir } from 'node:os';
4
5
  import { join, resolve } from 'node:path';
5
6
  import type { ModelChoice } from './compactor.ts';
6
7
  import { record } from './cache.ts';
8
+ import { atomicWrite } from './memory.ts';
7
9
  import { DEFAULT_SETTINGS, readSettings, type Settings } from './settings.ts';
8
10
 
11
+ export const isWindows = process.platform === 'win32';
12
+ const PIPE_PREFIX = '\\\\.\\pipe\\';
13
+
9
14
  export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
10
15
  export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice }
11
16
  export const defaults: ProfileConfig = {
@@ -22,22 +27,21 @@ export function listProfiles() {
22
27
  const root = join(dataHome(), 'profiles');
23
28
  return existsSync(root) ? readdirSync(root, { withFileTypes: true }).filter(f => f.isDirectory() && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(f.name)).map(f => f.name).sort() : [];
24
29
  }
25
- export function atomicWrite(file: string, text: string) {
26
- mkdirSync(resolve(file, '..'), { recursive: true, mode: 0o700 });
27
- const temporary = `${file}.${process.pid}.tmp`;
28
- writeFileSync(temporary, text, { mode: 0o600 });
29
- const fd = openSync(temporary, 'r');
30
- try { fsyncSync(fd); } finally { closeSync(fd); }
31
- renameSync(temporary, file);
32
- const parent = openSync(resolve(file, '..'), 'r');
33
- try { fsyncSync(parent); } finally { closeSync(parent); }
34
- }
35
30
  export function createProfile(name: string) {
36
31
  const dir = profilePath(name);
37
32
  if (existsSync(dir)) throw new Error(`Profile already exists: ${name}`);
38
- mkdirSync(dir, { recursive: true, mode: 0o700 });
39
- saveConfig(dir, defaults);
40
- atomicWrite(join(dir, 'AGENTS.md'), `# ${name}\n\nThis is the ${name} profile.\nAdd your personal instructions here.\n`);
33
+ mkdirSync(resolve(dir, '..'), { recursive: true, mode: 0o700 });
34
+ // Claim the directory exclusively, so a competing creator cannot have its
35
+ // profile overwritten or removed by this attempt's cleanup.
36
+ mkdirSync(dir, { mode: 0o700 });
37
+ try {
38
+ saveConfig(dir, defaults);
39
+ atomicWrite(join(dir, 'AGENTS.md'), `# ${name}\n\nThis is the ${name} profile.\nAdd your personal instructions here.\n`);
40
+ } catch (error) {
41
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); }
42
+ catch { throw new Error(`Failed to create profile ${name}, and could not remove its incomplete directory: ${dir}`, { cause: error }); }
43
+ throw error;
44
+ }
41
45
  }
42
46
  export function saveConfig(dir: string, config: ProfileConfig) { atomicWrite(join(dir, 'config.json'), JSON.stringify(config, null, 2) + '\n'); }
43
47
  function modelChoice(value: unknown): value is ModelChoice {
@@ -60,24 +64,41 @@ export function rememberProfile(name: string) { atomicWrite(join(dataHome(), 'la
60
64
  export class ProfileBusyError extends Error {
61
65
  constructor(readonly owner: string) { super(`Profile already running: ${owner}`); }
62
66
  }
63
- /** Lives in the profile itself, so every Pi on this profile finds the same socket whatever its TMPDIR. Git skips sockets, so checkpoints never see it. */
64
- export const profileSocket = (dir: string, purpose: 'lock' | 'windows' = 'lock') => join(dir, `${purpose}.sock`);
67
+ /** POSIX keeps a socket file in the profile itself, so every Pi on the profile finds the same one whatever its TMPDIR, and Git skips it. Windows has no Unix sockets, so the profile path is hashed into a named pipe in the machine-wide pipe namespace. */
68
+ export function profileSocket(dir: string, purpose: 'lock' | 'windows' = 'lock') {
69
+ if (!isWindows) return join(dir, `${purpose}.sock`);
70
+ // Hash the directory's real identity, so another casing, a junction or a symlink to the same profile finds the same pipe.
71
+ let identity = resolve(dir); try { identity = realpathSync.native(identity); } catch {}
72
+ return `${PIPE_PREFIX}optchat-${createHash('sha256').update(identity.toLowerCase()).digest('hex').slice(0, 16)}-${purpose}`;
73
+ }
65
74
 
66
- // sun_path is 104 bytes on macOS and 108 elsewhere, both including the NUL. Node 22 binds a truncated path instead of failing, so the length is checked before listen.
67
- export const SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 103 : 107;
75
+ // sun_path is 104 bytes on macOS and 108 elsewhere, both including the NUL; a Windows named pipe path is capped at 259 characters, which for the ASCII names OptChat generates is also its byte length. Node 22 binds a truncated socket instead of failing, so the length is checked before listen.
76
+ export const SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 103 : isWindows ? 259 : 107;
68
77
  export function checkSocketPath(path: string) {
69
78
  const length = Buffer.byteLength(path);
70
79
  if (length > SOCKET_PATH_LIMIT) throw new Error(`Cannot listen on the profile socket: its path is ${length} bytes, over this system's limit of ${SOCKET_PATH_LIMIT}. Set OPTCHAT_HOME to a shorter directory: ${path}`);
71
80
  }
72
81
 
73
- /** A socket that may be replaced; a file that only shares its name is refused, never deleted. */
74
- function existingSocket(path: string) {
82
+ /** A socket that may be replaced; a file that only shares its name is refused, never deleted. A Windows pipe has no filesystem entry, so there is nothing to check or remove. */
83
+ function existingSocket(path: string): { stat: Stats; removable: true } | { removable: false } | undefined {
84
+ if (isWindows) return { removable: false };
75
85
  let stat; try { stat = lstatSync(path); } catch { return undefined; }
76
86
  if (!stat.isSocket()) throw new Error(`${path} is not an OptChat socket. Move it out of the profile and try again.`);
77
- return stat;
87
+ return { stat, removable: true };
78
88
  }
79
89
  /** Removes a dead socket left by a previous owner. */
80
- export function removeStaleSocket(path: string) { if (existingSocket(path)) unlinkSync(path); }
90
+ export function removeStaleSocket(path: string) { if (existingSocket(path)?.removable) unlinkSync(path); }
91
+
92
+ /** Asks the current owner who holds the socket; nothing answering means the entry is stale. A Windows pipe that has disappeared reports ENOENT instead of ECONNREFUSED. */
93
+ function readOwner(path: string) {
94
+ return new Promise<string | undefined>((resolve, reject) => {
95
+ const socket = createConnection(path); let message = '';
96
+ socket.setTimeout(1500, () => { socket.destroy(); reject(new Error('Profile lock did not respond; refusing to steal it.')); });
97
+ socket.on('data', data => { message += data.toString(); });
98
+ socket.on('end', () => resolve(message || 'another Pi instance'));
99
+ socket.on('error', e => { if ('code' in e && (e.code === 'ECONNREFUSED' || isWindows && e.code === 'ENOENT')) resolve(undefined); else reject(e); });
100
+ });
101
+ }
81
102
 
82
103
  /** OS-owned socket lifetime, no timeout-based stealing of a busy profile. */
83
104
  export async function lockProfile(dir: string, description: string) {
@@ -93,18 +114,16 @@ export async function lockProfile(dir: string, description: string) {
93
114
  if (!(error instanceof Error) || !('code' in error) || error.code !== 'EADDRINUSE') throw error;
94
115
  const before = existingSocket(socketPath);
95
116
  if (!before) throw new Error('Profile lock changed; try again.');
96
- const owner = await new Promise<string | undefined>((resolve, reject) => {
97
- const socket = createConnection(socketPath); let message = '';
98
- socket.setTimeout(1500, () => { socket.destroy(); reject(new Error('Profile lock did not respond; refusing to steal it.')); });
99
- socket.on('data', data => { message += data.toString(); });
100
- socket.on('end', () => resolve(message || 'another Pi instance'));
101
- socket.on('error', e => { if ('code' in e && e.code === 'ECONNREFUSED') resolve(undefined); else reject(e); });
102
- });
117
+ const owner = await readOwner(socketPath);
103
118
  if (owner !== undefined) throw new ProfileBusyError(owner);
104
- if (statSync(socketPath).ino !== before.ino) throw new Error('Profile lock changed; try again.');
105
- unlinkSync(socketPath); await listen();
119
+ // Nothing answered, so the entry is stale. POSIX leaves a file behind that can be removed; a Windows pipe disappears with its owner, so rebinding is the whole cleanup.
120
+ if (before.removable) {
121
+ if (statSync(socketPath).ino !== before.stat.ino) throw new Error('Profile lock changed; try again.');
122
+ unlinkSync(socketPath);
123
+ }
124
+ await listen();
106
125
  }
107
126
  const unlock = () => new Promise<void>(resolve => server.close(() => resolve()));
108
- try { chmodSync(socketPath, 0o600); } catch (error) { await unlock(); throw error; }
127
+ if (!isWindows) { try { chmodSync(socketPath, 0o600); } catch (error) { await unlock(); throw error; } }
109
128
  return unlock;
110
129
  }