pi-optchat 0.10.1 → 0.11.0

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.
Files changed (47) hide show
  1. package/README.md +4 -2
  2. package/docs/core.md +38 -0
  3. package/docs/durable.md +35 -0
  4. package/docs/recipe-differences.md +1 -1
  5. package/docs/storage.md +2 -2
  6. package/docs/subagents.md +5 -1
  7. package/docs/victor-recipe.md +4 -4
  8. package/package.json +38 -12
  9. package/src/agent-view.ts +5 -6
  10. package/src/agents.ts +19 -12
  11. package/src/browser.ts +11 -7
  12. package/src/compactor.ts +10 -97
  13. package/src/connected-window.ts +4 -4
  14. package/src/core/compactor.ts +119 -0
  15. package/src/core/images.ts +24 -0
  16. package/src/core/import.ts +84 -0
  17. package/src/core/index.ts +13 -0
  18. package/src/core/memory.ts +460 -0
  19. package/src/core/messages.ts +108 -0
  20. package/src/core/prompts.ts +153 -0
  21. package/src/core/store.ts +48 -0
  22. package/src/core/tools.ts +64 -0
  23. package/src/durable/import.ts +114 -0
  24. package/src/durable/index.ts +5 -0
  25. package/src/durable/optchat.ts +274 -0
  26. package/src/durable/store.ts +162 -0
  27. package/src/handoff.ts +4 -3
  28. package/src/images.ts +2 -12
  29. package/src/import/job.ts +19 -51
  30. package/src/import/sources.ts +6 -4
  31. package/src/import/ui.ts +3 -3
  32. package/src/inbox.ts +1 -1
  33. package/src/index.ts +26 -38
  34. package/src/inspector.ts +3 -1
  35. package/src/memory.ts +18 -416
  36. package/src/profiles.ts +3 -2
  37. package/src/prompts.ts +1 -149
  38. package/src/report-message.ts +5 -3
  39. package/src/runs.ts +6 -1
  40. package/src/settings.ts +3 -2
  41. package/src/store.ts +16 -42
  42. package/src/title.ts +3 -2
  43. package/src/tools.ts +5 -62
  44. package/src/transcript.ts +18 -101
  45. package/src/usage.ts +1 -1
  46. package/src/window-bridge.ts +9 -6
  47. /package/src/{cache.ts → core/cache.ts} +0 -0
@@ -0,0 +1,119 @@
1
+ import type { Api, AssistantMessage, Message, Model, Models, SimpleStreamOptions } from '@earendil-works/pi-ai';
2
+ import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
3
+ import { bytes, cut, NODE, Refused, type Compressor } from './memory.ts';
4
+ import { cachePayload, splitView } from './cache.ts';
5
+
6
+ /** The longest line kept without asking again, unless the host sets its own (Pi's "Summary size tolerance"). */
7
+ export const ACCEPTED_BYTES = NODE * 1.25;
8
+ const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
9
+
10
+ /** Parallel calls can't read a cache entry that isn't written yet, so one call primes a cold prefix and the rest wait until it answers.
11
+ * Parallel compactions end their views at different messages, so a call also waits for a primer of a shorter prefix of its own view. */
12
+ function primeFirst() {
13
+ const warm = new Map<string, number | Promise<void>>();
14
+ return async (prefix: string, signal: AbortSignal) => {
15
+ const priming = () => { for (const [k, state] of warm) if (typeof state !== 'number' && prefix.startsWith(k)) return state; };
16
+ for (let state = warm.get(prefix) ?? priming(); state !== undefined; state = warm.get(prefix) ?? priming()) {
17
+ if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
18
+ // A cancelled waiter leaves at once instead of waiting for someone else's primer.
19
+ signal.throwIfAborted();
20
+ let wake = () => {};
21
+ const aborted = new Promise<void>(resolve => { wake = resolve; });
22
+ signal.addEventListener('abort', wake, { once: true });
23
+ await Promise.race([state, aborted]);
24
+ signal.removeEventListener('abort', wake);
25
+ signal.throwIfAborted();
26
+ }
27
+ let release = () => {};
28
+ const pending = warm.has(prefix) ? undefined : new Promise<void>(resolve => { release = resolve; });
29
+ if (pending) warm.set(prefix, pending);
30
+ return (ok: boolean) => {
31
+ if (ok) { for (const [k, at] of warm) if (typeof at === 'number' && Date.now() - at >= WARM_MS) warm.delete(k); warm.set(prefix, Date.now()); }
32
+ else if (warm.get(prefix) === pending) warm.delete(prefix);
33
+ release();
34
+ };
35
+ };
36
+ }
37
+ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
38
+ signal?.throwIfAborted();
39
+ const abort = () => { clearTimeout(timer); reject(signal?.reason); };
40
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
41
+ signal?.addEventListener('abort', abort, { once: true });
42
+ });
43
+ /** Whether a failed reply is worth asking again: pi-ai's `isRetryableAssistantError`, passed in by the host. */
44
+ export type Retryable = (reply: AssistantMessage) => boolean;
45
+ /** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
46
+ export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void, retryable: Retryable,
47
+ delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
48
+ for (let retry = 0; ; retry++) {
49
+ const reply = await ask();
50
+ onReply(reply);
51
+ if (retry >= delays.length || !retryable(reply)) return reply;
52
+ await sleep(delays[retry], signal);
53
+ }
54
+ }
55
+
56
+ /** The compactor's model for one call, as the host resolves it: `stream` is pi-ai's `Models.streamSimple`, or Pi's
57
+ * `ModelRegistry.streamSimple`, which has its shape. */
58
+ export interface CompactorModel { model: Model<Api>; reasoning?: SimpleStreamOptions['reasoning']; stream: Models['streamSimple'] }
59
+ export interface CompressorOptions {
60
+ /** Resolves the model for each call, so a settings change applies to the next summary. Throws if it is unavailable. */
61
+ model: () => CompactorModel;
62
+ retryable: Retryable;
63
+ onUsage?: (reply: AssistantMessage) => void;
64
+ /** The longest line kept without a retry; ACCEPTED_BYTES unless the host has a setting for it. */
65
+ accepted?: () => number;
66
+ retryDelays?: readonly number[];
67
+ /** The compactor's system prompt: COMPACT, unless the host's chat isn't one user and OptChat. */
68
+ system?: string;
69
+ }
70
+
71
+ /** Builds summary lines with the model `model` resolves. The model is asked for 512 bytes. */
72
+ export function createCompressor({ model: resolve, retryable, onUsage = () => {}, accepted = () => ACCEPTED_BYTES, retryDelays,
73
+ system = COMPACT }: CompressorOptions): Compressor {
74
+ const gate = primeFirst();
75
+ return async (input, signal) => {
76
+ const { model, reasoning: thinking, stream: streamSimple } = resolve();
77
+ const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
78
+ // OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
79
+ const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
80
+ const view = splitView(input.context);
81
+ const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
82
+ const tries: string[] = [];
83
+ const ask = async () => {
84
+ const warmed = prefix ? await gate(prefix, signal) : () => {};
85
+ try {
86
+ const stream = streamSimple(model, { systemPrompt: system, messages }, {
87
+ // A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
88
+ sessionId: 'optchat-compactor', transport: 'sse',
89
+ reasoning: thinking, signal, cacheRetention: 'short',
90
+ onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
91
+ });
92
+ // The cache entry is usable once the model starts answering.
93
+ for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
94
+ return await stream.result();
95
+ } finally { warmed(false); }
96
+ };
97
+ const answer = () => retryTransient(ask, onUsage, retryable, retryDelays, signal);
98
+ for (let attempt = 0; attempt < 5; attempt++) {
99
+ const reply = await answer();
100
+ // A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
101
+ // A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
102
+ if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !retryable(reply))
103
+ throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
104
+ if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
105
+ // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
106
+ if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
107
+ // The compactions' view shows each line under its id+n| head, which a line can copy.
108
+ const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
109
+ if (!line) throw new Refused('Compactor returned no text.');
110
+ tries.push(line);
111
+ // A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
112
+ if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
113
+ messages.push(reply);
114
+ messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
115
+ }
116
+ if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
117
+ return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
118
+ };
119
+ }
@@ -0,0 +1,24 @@
1
+ import type { ImageContent } from '@earendil-works/pi-ai';
2
+ import type { Store } from './store.ts';
3
+
4
+ /** How a message's image reads in memory: `[image <16 hex digits>]`, named by the host (Pi: a hash of the image as sent). */
5
+ const REF = /\[image ([0-9a-f]{16})\]/g;
6
+
7
+ export const isImage = (part: unknown): part is ImageContent => typeof part === 'object' && part !== null
8
+ && 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
9
+
10
+ /** Base64 in chunks: one String.fromCharCode call per image would pass the engine's argument limit. */
11
+ const base64 = (data: Uint8Array) => {
12
+ let binary = '';
13
+ for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000));
14
+ return btoa(binary);
15
+ };
16
+ /** An image's base64 as bytes. */
17
+ export const decode = (data: string) => Uint8Array.from(atob(data), char => char.charCodeAt(0));
18
+
19
+ /** The kept images a text refers to, in order, each once; ones never kept are skipped. */
20
+ export async function loadImages(store: Store, text: string): Promise<ImageContent[]> {
21
+ const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
22
+ return (await Promise.all(names.map(name => store.image(name))))
23
+ .flatMap(kept => kept ? [{ type: 'image' as const, data: base64(kept.data), mimeType: kept.mimeType }] : []);
24
+ }
@@ -0,0 +1,84 @@
1
+ import { bytes, isEntry, type Entry, type Memory } from './memory.ts';
2
+
3
+ /** What planning reads of a message: no text, so a host can plan from metadata alone. */
4
+ type Meta = Pick<Entry, 'kind' | 'date' | 'receipt' | 'origin'>;
5
+
6
+ /** Append keeps the existing log and its summaries and adds the new messages after them; rebuild orders everything by date and
7
+ * summarizes it all again. Either way the host builds the result on a staging store and switches to it only once it is complete. */
8
+ export type ImportMode = 'append' | 'rebuild';
9
+ /** A message to import, before memory numbers it. Its `receipt` starts with `import:` and is stable per source message, so
10
+ * importing the same history again adds nothing. */
11
+ export type ImportedEntry = Omit<Entry, 'i'>;
12
+
13
+ /** Drops what memory already has: the same receipt, or the same `copyOf` key, the host's second identity for one message
14
+ * (Pi: a Claude message copied into a forked session). */
15
+ export function deduplicate<E extends Meta>(existing: readonly E[], incoming: readonly ImportedEntry[],
16
+ copyOf: (entry: E | ImportedEntry) => string | undefined = () => undefined) {
17
+ const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
18
+ const copies = new Set(existing.map(copyOf).filter(Boolean));
19
+ const added: ImportedEntry[] = []; let skipped = 0;
20
+ for (const entry of incoming) {
21
+ if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
22
+ const copy = copyOf(entry);
23
+ if (receipts.has(entry.receipt) || copy && copies.has(copy)) { skipped++; continue; }
24
+ receipts.add(entry.receipt); if (copy) copies.add(copy); added.push(entry);
25
+ }
26
+ return { added, skipped };
27
+ }
28
+
29
+ /** Imported conversations remain contiguous; existing native history is grouped by user turn. An imported message without an
30
+ * origin stands alone: in a native turn it would move that turn, and a host's cursor in the log with it. */
31
+ export function chronological<E extends Meta>(entries: readonly E[]) {
32
+ const groups = new Map<string, { date: string; entries: E[] }>();
33
+ let native = 0;
34
+ for (const entry of entries) {
35
+ const imported = entry.receipt?.startsWith('import:') ? entry.receipt : undefined;
36
+ if (!entry.origin && !imported && entry.kind === 'user') native++;
37
+ const id = entry.origin ? JSON.stringify([entry.origin.source, entry.origin.conversation]) : imported ?? `native:${native}`;
38
+ const group = groups.get(id) ?? { date: entry.date, entries: [] };
39
+ group.date = group.date < entry.date ? group.date : entry.date;
40
+ group.entries.push(entry); groups.set(id, group);
41
+ }
42
+ return [...groups.values()].sort((a, b) => a.date.localeCompare(b.date)).flatMap(g => g.entries);
43
+ }
44
+
45
+ /** The whole log an import builds, or undefined when nothing is new. Its first `kept` entries are the existing log as it is (append),
46
+ * so the host seeds the staging store with them and the existing summaries and view; rebuild keeps none. */
47
+ export function planImport(existing: readonly Entry[], incoming: readonly ImportedEntry[], mode: ImportMode,
48
+ copyOf?: (entry: ImportedEntry) => string | undefined) {
49
+ const { added, skipped } = deduplicate(existing, incoming, copyOf);
50
+ if (!added.length) return undefined;
51
+ const all = mode === 'rebuild' ? chronological([...existing, ...added]) : [...existing, ...chronological(added)];
52
+ const entries: Entry[] = all.map((e, i) => ({ ...e, i }));
53
+ return { mode, entries, kept: mode === 'append' ? existing.length : 0, added: added.length, skipped,
54
+ inputBytes: (mode === 'rebuild' ? entries : added).reduce((n, e) => n + bytes(e.text), 0) };
55
+ }
56
+
57
+ /** Builds the staging memory `open` gives from the plan, `entry(i)` for each of its `total` messages, and calls `activate` once its
58
+ * whole tree is built and it is closed. It continues from what the memory already logged, so a paused or crashed import resumes. A
59
+ * planned message that isn't a valid entry at its index, or a memory that isn't a start of the plan, stops it before the switch;
60
+ * `entry` is read as needed, so a host need not hold the plan in RAM.
61
+ * The live memory must take no messages from `planImport` until `activate`, or the switch drops them: queue them and log them after. */
62
+ export async function runImport({ entry, total, open, activate }: { entry: (i: number) => unknown; total: number;
63
+ open: () => Promise<Memory>; activate: () => void | Promise<void> }, signal: AbortSignal) {
64
+ const planned = (i: number) => {
65
+ const e = entry(i);
66
+ if (!isEntry(e) || e.i !== i) throw new Error('Import staging data is invalid; original memory remains intact.');
67
+ return e;
68
+ };
69
+ if (!Number.isSafeInteger(total) || total < 0) throw new Error('Import staging data is invalid; original memory remains intact.');
70
+ const memory = await open();
71
+ try {
72
+ if (memory.root.length > total || memory.root.some((_, i) => memory.text(i) !== planned(i).text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
73
+ // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
74
+ // messages is, so up to `jobs` summaries run at once. Unlike a turn, an import waits through failures (`lastError` says why).
75
+ for (let i = memory.root.length; i < total; i++) { await memory.settle(signal, 'ahead'); const e = planned(i); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
76
+ await memory.settle(signal, 'tree'); signal.throwIfAborted();
77
+ // Close all writers before the switch. The previous memory stays intact.
78
+ await memory.close();
79
+ // A store that writes behind reports a failed write only here: switching would drop the messages it lost.
80
+ if (memory.saveError !== undefined) throw new Error(`Import could not be saved: ${memory.saveError}; original memory remains intact.`);
81
+ signal.throwIfAborted();
82
+ await activate();
83
+ } finally { await memory.close(); }
84
+ }
@@ -0,0 +1,13 @@
1
+ /** OptChat's memory for any host: the log, the summary tree and its views, zoom/date/search, the compactor, cache marks and
2
+ * memory's prompts. It imports nothing from Pi or Node at runtime: the host gives it a `Store` and the compactor's model.
3
+ * Only what a host needs is exported here; Pi's own code imports the modules directly. */
4
+ export { isEntry, Memory, openMemory, Refused, VIEW, type Compression, type Compressor, type Entry, type Kind, type MemoryOptions, type Origin, type Part, type Summary } from './memory.ts';
5
+ export { isMime, type ImageStore, type Lease, type Mime, type Saved, type Store } from './store.ts';
6
+ export { ACCEPTED_BYTES, createCompressor, type CompactorModel, type CompressorOptions, type Retryable } from './compactor.ts';
7
+ export { cachePayload } from './cache.ts';
8
+ export { allowSearch, COMPACT, CONTINUITY, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
9
+ export { boundedMessage, completedExchange, logMessage, refreshBudget, refreshedRun, refreshPoint, textContent, withView, type AnyMessage, type ImageRef } from './messages.ts';
10
+ export { memoryTools, searchTool } from './tools.ts';
11
+ export { decode, isImage, loadImages } from './images.ts';
12
+ export type { ImageContent } from '@earendil-works/pi-ai';
13
+ export { chronological, deduplicate, planImport, runImport, type ImportedEntry, type ImportMode } from './import.ts';