pi-optchat 0.11.1 → 0.11.3
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 +1 -1
- package/dist/core/cache.d.ts +14 -0
- package/dist/core/cache.js +96 -0
- package/dist/core/compactor.d.ts +28 -0
- package/dist/core/compactor.js +123 -0
- package/dist/core/images.d.ts +7 -0
- package/dist/core/images.js +19 -0
- package/dist/core/import.d.ts +40 -0
- package/dist/core/import.js +90 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +12 -0
- package/dist/core/memory.d.ts +196 -0
- package/dist/core/memory.js +573 -0
- package/dist/core/messages.d.ts +42 -0
- package/dist/core/messages.js +122 -0
- package/dist/core/prompts.d.ts +22 -0
- package/dist/core/prompts.js +146 -0
- package/dist/core/store.d.ts +57 -0
- package/dist/core/store.js +3 -0
- package/dist/core/tools.d.ts +76 -0
- package/dist/core/tools.js +64 -0
- package/dist/durable/import.d.ts +64 -0
- package/dist/durable/import.js +109 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +5 -0
- package/dist/durable/optchat.d.ts +42 -0
- package/dist/durable/optchat.js +262 -0
- package/dist/durable/store.d.ts +65 -0
- package/dist/durable/store.js +167 -0
- package/docs/core.md +1 -1
- package/docs/durable.md +1 -1
- package/package.json +13 -3
- package/src/index.ts +17 -5
- package/src/profiles.ts +1 -1
- package/src/transcript.ts +16 -9
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windo
|
|
|
79
79
|
| Subagents | Anthropic Opus 5.5, high | `/optchat agents model` |
|
|
80
80
|
| Compactor (summaries, imports, fallback handoffs) | Anthropic Sonnet 5.5, medium | `/optchat model` |
|
|
81
81
|
|
|
82
|
-
All three are saved per profile. The main agent's model and thinking level are saved whenever you change them in Pi
|
|
82
|
+
All three are saved per profile. The main agent's model and thinking level are saved when you first open the profile and whenever you change them in Pi, then picked again when the profile opens, unless the saved model is no longer available. This includes resumed sessions: the profile's pick replaces the one the session last used. On Pi's command line, `--model` (or `--provider`) keeps that model, `--thinking` that level, and `--models` keeps a saved model only if it is in that list. Subagent and compactor settings do not follow the main model. If you use other providers, change them before chatting. Authentication uses Pi's existing provider login.
|
|
83
83
|
|
|
84
84
|
Compression and subagents make extra model requests with your provider credentials.
|
|
85
85
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function record(value: unknown): value is Record<string, unknown>;
|
|
2
|
+
/** The view in blocks of 4 lines counted from its start (recipe §3.3), byte for byte. While the view only grows at its end,
|
|
3
|
+
* every block but the last is the same in the next call. */
|
|
4
|
+
export declare function splitView(text: string): string[];
|
|
5
|
+
/** The status line's own message, in Pi's context and in Anthropic's payload alike. */
|
|
6
|
+
export declare const isAtWork: (message: unknown) => boolean;
|
|
7
|
+
/** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
|
|
8
|
+
* and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
|
|
9
|
+
* a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
|
|
10
|
+
* same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
|
|
11
|
+
* `end: false` leaves the end unmarked: a compaction's task is never sent again, so marking it would only write it at 1.25× (#197). */
|
|
12
|
+
export declare function cachePayload(payload: unknown, { end: markEnd }?: {
|
|
13
|
+
end?: boolean | undefined;
|
|
14
|
+
}): unknown;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isView } from './memory.js';
|
|
2
|
+
import { AT_WORK } from './prompts.js';
|
|
3
|
+
export function record(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
/** The view in blocks of 4 lines counted from its start (recipe §3.3), byte for byte. While the view only grows at its end,
|
|
7
|
+
* every block but the last is the same in the next call. */
|
|
8
|
+
export function splitView(text) {
|
|
9
|
+
const pieces = [];
|
|
10
|
+
let offset = 0;
|
|
11
|
+
for (let count = 0, at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n', at + 1))
|
|
12
|
+
if (++count % 4 === 0) {
|
|
13
|
+
pieces.push(text.slice(offset, at + 1));
|
|
14
|
+
offset = at + 1;
|
|
15
|
+
}
|
|
16
|
+
pieces.push(text.slice(offset));
|
|
17
|
+
return pieces;
|
|
18
|
+
}
|
|
19
|
+
/** How many blocks before the view's last whole one each mark sits. Anthropic looks back only 20 blocks from a mark for an
|
|
20
|
+
* earlier entry, so the marks 20 and 40 blocks back still find the last call's entry after a turn of tool calls adds up to
|
|
21
|
+
* 60 blocks (240 lines). With the end of the request, that is Anthropic's limit of 4 marks. */
|
|
22
|
+
const MARKS = [0, 20, 40];
|
|
23
|
+
/** The status line's own message, in Pi's context and in Anthropic's payload alike. */
|
|
24
|
+
export const isAtWork = (message) => record(message) && message.role === 'user' && Array.isArray(message.content) && message.content.length === 1
|
|
25
|
+
&& record(message.content[0]) && typeof message.content[0].text === 'string' && message.content[0].text.startsWith(AT_WORK);
|
|
26
|
+
function lastBlock(message) {
|
|
27
|
+
if (typeof message.content === 'string')
|
|
28
|
+
message.content = [{ type: 'text', text: message.content }];
|
|
29
|
+
const block = Array.isArray(message.content) ? message.content.at(-1) : undefined;
|
|
30
|
+
return record(block) ? block : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
|
|
33
|
+
* and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
|
|
34
|
+
* a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
|
|
35
|
+
* same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
|
|
36
|
+
* `end: false` leaves the end unmarked: a compaction's task is never sent again, so marking it would only write it at 1.25× (#197). */
|
|
37
|
+
export function cachePayload(payload, { end: markEnd = true } = {}) {
|
|
38
|
+
if (!record(payload) || !Array.isArray(payload.messages))
|
|
39
|
+
return payload;
|
|
40
|
+
const messages = payload.messages;
|
|
41
|
+
const view = new Set();
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (!record(message) || message.role !== 'user' || !Array.isArray(message.content))
|
|
44
|
+
continue;
|
|
45
|
+
const at = message.content.findIndex((block) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
|
|
46
|
+
if (at < 0)
|
|
47
|
+
continue;
|
|
48
|
+
// A subagent's task follows the view in the same text. Lines never hold a newline, so the first one before </chat> ends the view;
|
|
49
|
+
// the task goes in its own block, so the view's marks fall on lines its siblings share and they can read one another's entry.
|
|
50
|
+
const text = message.content[at].text, close = text.indexOf('\n</chat>'), end = close < 0 ? text.length : close + '\n</chat>'.length;
|
|
51
|
+
const pieces = splitView(text.slice(0, end)), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
|
|
52
|
+
const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(marked.has(j) ? { cache_control: { type: 'ephemeral' } } : {}) }));
|
|
53
|
+
message.content.splice(at, 1, ...blocks, ...(end < text.length ? [{ type: 'text', text: text.slice(end) }] : []));
|
|
54
|
+
for (const block of blocks)
|
|
55
|
+
view.add(block);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (!view.size)
|
|
59
|
+
return payload;
|
|
60
|
+
// These marks only: Pi's own system, tool and recent-message marks are dropped.
|
|
61
|
+
for (const section of [payload.system, payload.tools]) {
|
|
62
|
+
if (Array.isArray(section))
|
|
63
|
+
for (const item of section)
|
|
64
|
+
if (record(item))
|
|
65
|
+
delete item.cache_control;
|
|
66
|
+
}
|
|
67
|
+
for (const message of messages) {
|
|
68
|
+
if (!record(message))
|
|
69
|
+
continue;
|
|
70
|
+
delete message.cache_control;
|
|
71
|
+
if (Array.isArray(message.content))
|
|
72
|
+
for (const item of message.content)
|
|
73
|
+
if (record(item) && !view.has(item))
|
|
74
|
+
delete item.cache_control;
|
|
75
|
+
}
|
|
76
|
+
// The status line changes between calls, so the turn's mark goes on the block before it rather than at the end of the request.
|
|
77
|
+
// Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
|
|
78
|
+
const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
|
|
79
|
+
const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
|
|
80
|
+
const end = markEnd ? turn ?? endBlock(messages) : undefined;
|
|
81
|
+
if (end)
|
|
82
|
+
end.cache_control = { type: 'ephemeral' };
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
/** The last block of the last non-system message that can carry a mark (thinking blocks can't). */
|
|
86
|
+
function endBlock(messages) {
|
|
87
|
+
const message = messages.filter(m => !record(m) || m.role !== 'system').at(-1);
|
|
88
|
+
if (!record(message))
|
|
89
|
+
return undefined;
|
|
90
|
+
if (typeof message.content === 'string')
|
|
91
|
+
return lastBlock(message);
|
|
92
|
+
if (!Array.isArray(message.content))
|
|
93
|
+
return undefined;
|
|
94
|
+
const block = message.content.findLast((b) => record(b) && b.type !== 'thinking' && b.type !== 'redacted_thinking');
|
|
95
|
+
return record(block) ? block : undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Api, AssistantMessage, Model, Models, SimpleStreamOptions } from '@earendil-works/pi-ai';
|
|
2
|
+
import { type Compressor } from './memory.ts';
|
|
3
|
+
/** The longest line kept without asking again, unless the host sets its own (Pi's "Summary size tolerance"). */
|
|
4
|
+
export declare const ACCEPTED_BYTES: number;
|
|
5
|
+
/** Whether a failed reply is worth asking again: pi-ai's `isRetryableAssistantError`, passed in by the host. */
|
|
6
|
+
export type Retryable = (reply: AssistantMessage) => boolean;
|
|
7
|
+
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
8
|
+
export declare function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void, retryable: Retryable, delays?: readonly number[], signal?: AbortSignal): Promise<AssistantMessage>;
|
|
9
|
+
/** The compactor's model for one call, as the host resolves it: `stream` is pi-ai's `Models.streamSimple`, or Pi's
|
|
10
|
+
* `ModelRegistry.streamSimple`, which has its shape. */
|
|
11
|
+
export interface CompactorModel {
|
|
12
|
+
model: Model<Api>;
|
|
13
|
+
reasoning?: SimpleStreamOptions['reasoning'];
|
|
14
|
+
stream: Models['streamSimple'];
|
|
15
|
+
}
|
|
16
|
+
export interface CompressorOptions {
|
|
17
|
+
/** Resolves the model for each call, so a settings change applies to the next summary. Throws if it is unavailable. */
|
|
18
|
+
model: () => CompactorModel;
|
|
19
|
+
retryable: Retryable;
|
|
20
|
+
onUsage?: (reply: AssistantMessage) => void;
|
|
21
|
+
/** The longest line kept without a retry; ACCEPTED_BYTES unless the host has a setting for it. */
|
|
22
|
+
accepted?: () => number;
|
|
23
|
+
retryDelays?: readonly number[];
|
|
24
|
+
/** The compactor's system prompt: COMPACT, unless the host's chat isn't one user and OptChat. */
|
|
25
|
+
system?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Builds summary lines with the model `model` resolves. The model is asked for 512 bytes. */
|
|
28
|
+
export declare function createCompressor({ model: resolve, retryable, onUsage, accepted, retryDelays, system }: CompressorOptions): Compressor;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.js';
|
|
2
|
+
import { bytes, cut, NODE, Refused } from './memory.js';
|
|
3
|
+
import { cachePayload, splitView } from './cache.js';
|
|
4
|
+
/** The longest line kept without asking again, unless the host sets its own (Pi's "Summary size tolerance"). */
|
|
5
|
+
export const ACCEPTED_BYTES = NODE * 1.25;
|
|
6
|
+
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
7
|
+
/** 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.
|
|
8
|
+
* Parallel compactions end their views at different messages, so a call also waits for a primer of a shorter prefix of its own view. */
|
|
9
|
+
function primeFirst() {
|
|
10
|
+
const warm = new Map();
|
|
11
|
+
return async (prefix, signal) => {
|
|
12
|
+
const priming = () => { for (const [k, state] of warm)
|
|
13
|
+
if (typeof state !== 'number' && prefix.startsWith(k))
|
|
14
|
+
return state; };
|
|
15
|
+
for (let state = warm.get(prefix) ?? priming(); state !== undefined; state = warm.get(prefix) ?? priming()) {
|
|
16
|
+
if (typeof state === 'number') {
|
|
17
|
+
if (Date.now() - state < WARM_MS)
|
|
18
|
+
break;
|
|
19
|
+
warm.delete(prefix);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// A cancelled waiter leaves at once instead of waiting for someone else's primer.
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
let wake = () => { };
|
|
25
|
+
const aborted = new Promise(resolve => { wake = resolve; });
|
|
26
|
+
signal.addEventListener('abort', wake, { once: true });
|
|
27
|
+
await Promise.race([state, aborted]);
|
|
28
|
+
signal.removeEventListener('abort', wake);
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
}
|
|
31
|
+
let release = () => { };
|
|
32
|
+
const pending = warm.has(prefix) ? undefined : new Promise(resolve => { release = resolve; });
|
|
33
|
+
if (pending)
|
|
34
|
+
warm.set(prefix, pending);
|
|
35
|
+
return (ok) => {
|
|
36
|
+
if (ok) {
|
|
37
|
+
for (const [k, at] of warm)
|
|
38
|
+
if (typeof at === 'number' && Date.now() - at >= WARM_MS)
|
|
39
|
+
warm.delete(k);
|
|
40
|
+
warm.set(prefix, Date.now());
|
|
41
|
+
}
|
|
42
|
+
else if (warm.get(prefix) === pending)
|
|
43
|
+
warm.delete(prefix);
|
|
44
|
+
release();
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const abort = () => { clearTimeout(timer); reject(signal?.reason); };
|
|
51
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
|
|
52
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
53
|
+
});
|
|
54
|
+
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
55
|
+
export async function retryTransient(ask, onReply, retryable, delays = [1000, 2000], signal) {
|
|
56
|
+
for (let retry = 0;; retry++) {
|
|
57
|
+
const reply = await ask();
|
|
58
|
+
onReply(reply);
|
|
59
|
+
if (retry >= delays.length || !retryable(reply))
|
|
60
|
+
return reply;
|
|
61
|
+
await sleep(delays[retry], signal);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Builds summary lines with the model `model` resolves. The model is asked for 512 bytes. */
|
|
65
|
+
export function createCompressor({ model: resolve, retryable, onUsage = () => { }, accepted = () => ACCEPTED_BYTES, retryDelays, system = COMPACT }) {
|
|
66
|
+
const gate = primeFirst();
|
|
67
|
+
return async (input, signal) => {
|
|
68
|
+
const { model, reasoning: thinking, stream: streamSimple } = resolve();
|
|
69
|
+
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
|
|
70
|
+
// OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
|
|
71
|
+
const messages = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
|
|
72
|
+
const view = splitView(input.context);
|
|
73
|
+
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
|
|
74
|
+
const tries = [];
|
|
75
|
+
const ask = async () => {
|
|
76
|
+
const warmed = prefix ? await gate(prefix, signal) : () => { };
|
|
77
|
+
try {
|
|
78
|
+
const stream = streamSimple(model, { systemPrompt: system, messages }, {
|
|
79
|
+
// 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.
|
|
80
|
+
sessionId: 'optchat-compactor', transport: 'sse',
|
|
81
|
+
reasoning: thinking, signal, cacheRetention: 'short',
|
|
82
|
+
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
|
|
83
|
+
});
|
|
84
|
+
// The cache entry is usable once the model starts answering.
|
|
85
|
+
for await (const event of stream)
|
|
86
|
+
if (event.type !== 'start') {
|
|
87
|
+
warmed(event.type !== 'error');
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
return await stream.result();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
warmed(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const answer = () => retryTransient(ask, onUsage, retryable, retryDelays, signal);
|
|
97
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
98
|
+
const reply = await answer();
|
|
99
|
+
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
100
|
+
// A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
|
|
101
|
+
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !retryable(reply))
|
|
102
|
+
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
103
|
+
if (reply.stopReason === 'error' || reply.stopReason === 'aborted')
|
|
104
|
+
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'))
|
|
107
|
+
continue;
|
|
108
|
+
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
109
|
+
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
110
|
+
if (!line)
|
|
111
|
+
throw new Refused('Compactor returned no text.');
|
|
112
|
+
tries.push(line);
|
|
113
|
+
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
114
|
+
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source))
|
|
115
|
+
break;
|
|
116
|
+
messages.push(reply);
|
|
117
|
+
messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
|
|
118
|
+
}
|
|
119
|
+
if (!tries.length)
|
|
120
|
+
throw new Refused('Compactor returned no whole line in 5 tries.');
|
|
121
|
+
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ImageContent } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { Store } from './store.ts';
|
|
3
|
+
export declare const isImage: (part: unknown) => part is ImageContent;
|
|
4
|
+
/** An image's base64 as bytes. */
|
|
5
|
+
export declare const decode: (data: string) => Uint8Array<ArrayBuffer>;
|
|
6
|
+
/** The kept images a text refers to, in order, each once; ones never kept are skipped. */
|
|
7
|
+
export declare function loadImages(store: Store, text: string): Promise<ImageContent[]>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** How a message's image reads in memory: `[image <16 hex digits>]`, named by the host (Pi: a hash of the image as sent). */
|
|
2
|
+
const REF = /\[image ([0-9a-f]{16})\]/g;
|
|
3
|
+
export const isImage = (part) => typeof part === 'object' && part !== null
|
|
4
|
+
&& 'type' in part && part.type === 'image' && 'data' in part && typeof part.data === 'string' && 'mimeType' in part && typeof part.mimeType === 'string';
|
|
5
|
+
/** Base64 in chunks: one String.fromCharCode call per image would pass the engine's argument limit. */
|
|
6
|
+
const base64 = (data) => {
|
|
7
|
+
let binary = '';
|
|
8
|
+
for (let i = 0; i < data.length; i += 0x8000)
|
|
9
|
+
binary += String.fromCharCode(...data.subarray(i, i + 0x8000));
|
|
10
|
+
return btoa(binary);
|
|
11
|
+
};
|
|
12
|
+
/** An image's base64 as bytes. */
|
|
13
|
+
export const decode = (data) => Uint8Array.from(atob(data), char => char.charCodeAt(0));
|
|
14
|
+
/** The kept images a text refers to, in order, each once; ones never kept are skipped. */
|
|
15
|
+
export async function loadImages(store, text) {
|
|
16
|
+
const names = [...new Set(Array.from(text.matchAll(REF), match => match[1]))];
|
|
17
|
+
return (await Promise.all(names.map(name => store.image(name))))
|
|
18
|
+
.flatMap(kept => kept ? [{ type: 'image', data: base64(kept.data), mimeType: kept.mimeType }] : []);
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Entry, type Memory } from './memory.ts';
|
|
2
|
+
/** What planning reads of a message: no text, so a host can plan from metadata alone. */
|
|
3
|
+
type Meta = Pick<Entry, 'kind' | 'date' | 'receipt' | 'origin'>;
|
|
4
|
+
/** Append keeps the existing log and its summaries and adds the new messages after them; rebuild orders everything by date and
|
|
5
|
+
* summarizes it all again. Either way the host builds the result on a staging store and switches to it only once it is complete. */
|
|
6
|
+
export type ImportMode = 'append' | 'rebuild';
|
|
7
|
+
/** A message to import, before memory numbers it. Its `receipt` starts with `import:` and is stable per source message, so
|
|
8
|
+
* importing the same history again adds nothing. */
|
|
9
|
+
export type ImportedEntry = Omit<Entry, 'i'>;
|
|
10
|
+
/** Drops what memory already has: the same receipt, or the same `copyOf` key, the host's second identity for one message
|
|
11
|
+
* (Pi: a Claude message copied into a forked session). */
|
|
12
|
+
export declare function deduplicate<E extends Meta>(existing: readonly E[], incoming: readonly ImportedEntry[], copyOf?: (entry: E | ImportedEntry) => string | undefined): {
|
|
13
|
+
added: ImportedEntry[];
|
|
14
|
+
skipped: number;
|
|
15
|
+
};
|
|
16
|
+
/** Imported conversations remain contiguous; existing native history is grouped by user turn. An imported message without an
|
|
17
|
+
* origin stands alone: in a native turn it would move that turn, and a host's cursor in the log with it. */
|
|
18
|
+
export declare function chronological<E extends Meta>(entries: readonly E[]): E[];
|
|
19
|
+
/** The whole log an import builds, or undefined when nothing is new. Its first `kept` entries are the existing log as it is (append),
|
|
20
|
+
* so the host seeds the staging store with them and the existing summaries and view; rebuild keeps none. */
|
|
21
|
+
export declare function planImport(existing: readonly Entry[], incoming: readonly ImportedEntry[], mode: ImportMode, copyOf?: (entry: ImportedEntry) => string | undefined): {
|
|
22
|
+
mode: ImportMode;
|
|
23
|
+
entries: Entry[];
|
|
24
|
+
kept: number;
|
|
25
|
+
added: number;
|
|
26
|
+
skipped: number;
|
|
27
|
+
inputBytes: number;
|
|
28
|
+
} | undefined;
|
|
29
|
+
/** Builds the staging memory `open` gives from the plan, `entry(i)` for each of its `total` messages, and calls `activate` once its
|
|
30
|
+
* whole tree is built and it is closed. It continues from what the memory already logged, so a paused or crashed import resumes. A
|
|
31
|
+
* 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;
|
|
32
|
+
* `entry` is read as needed, so a host need not hold the plan in RAM.
|
|
33
|
+
* The live memory must take no messages from `planImport` until `activate`, or the switch drops them: queue them and log them after. */
|
|
34
|
+
export declare function runImport({ entry, total, open, activate }: {
|
|
35
|
+
entry: (i: number) => unknown;
|
|
36
|
+
total: number;
|
|
37
|
+
open: () => Promise<Memory>;
|
|
38
|
+
activate: () => void | Promise<void>;
|
|
39
|
+
}, signal: AbortSignal): Promise<void>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { bytes, isEntry } from './memory.js';
|
|
2
|
+
/** Drops what memory already has: the same receipt, or the same `copyOf` key, the host's second identity for one message
|
|
3
|
+
* (Pi: a Claude message copied into a forked session). */
|
|
4
|
+
export function deduplicate(existing, incoming, copyOf = () => undefined) {
|
|
5
|
+
const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
|
|
6
|
+
const copies = new Set(existing.map(copyOf).filter(Boolean));
|
|
7
|
+
const added = [];
|
|
8
|
+
let skipped = 0;
|
|
9
|
+
for (const entry of incoming) {
|
|
10
|
+
if (!entry.receipt?.startsWith('import:'))
|
|
11
|
+
throw new Error('Imported entry has no stable source identity.');
|
|
12
|
+
const copy = copyOf(entry);
|
|
13
|
+
if (receipts.has(entry.receipt) || copy && copies.has(copy)) {
|
|
14
|
+
skipped++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
receipts.add(entry.receipt);
|
|
18
|
+
if (copy)
|
|
19
|
+
copies.add(copy);
|
|
20
|
+
added.push(entry);
|
|
21
|
+
}
|
|
22
|
+
return { added, skipped };
|
|
23
|
+
}
|
|
24
|
+
/** Imported conversations remain contiguous; existing native history is grouped by user turn. An imported message without an
|
|
25
|
+
* origin stands alone: in a native turn it would move that turn, and a host's cursor in the log with it. */
|
|
26
|
+
export function chronological(entries) {
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
let native = 0;
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const imported = entry.receipt?.startsWith('import:') ? entry.receipt : undefined;
|
|
31
|
+
if (!entry.origin && !imported && entry.kind === 'user')
|
|
32
|
+
native++;
|
|
33
|
+
const id = entry.origin ? JSON.stringify([entry.origin.source, entry.origin.conversation]) : imported ?? `native:${native}`;
|
|
34
|
+
const group = groups.get(id) ?? { date: entry.date, entries: [] };
|
|
35
|
+
group.date = group.date < entry.date ? group.date : entry.date;
|
|
36
|
+
group.entries.push(entry);
|
|
37
|
+
groups.set(id, group);
|
|
38
|
+
}
|
|
39
|
+
return [...groups.values()].sort((a, b) => a.date.localeCompare(b.date)).flatMap(g => g.entries);
|
|
40
|
+
}
|
|
41
|
+
/** The whole log an import builds, or undefined when nothing is new. Its first `kept` entries are the existing log as it is (append),
|
|
42
|
+
* so the host seeds the staging store with them and the existing summaries and view; rebuild keeps none. */
|
|
43
|
+
export function planImport(existing, incoming, mode, copyOf) {
|
|
44
|
+
const { added, skipped } = deduplicate(existing, incoming, copyOf);
|
|
45
|
+
if (!added.length)
|
|
46
|
+
return undefined;
|
|
47
|
+
const all = mode === 'rebuild' ? chronological([...existing, ...added]) : [...existing, ...chronological(added)];
|
|
48
|
+
const entries = all.map((e, i) => ({ ...e, i }));
|
|
49
|
+
return { mode, entries, kept: mode === 'append' ? existing.length : 0, added: added.length, skipped,
|
|
50
|
+
inputBytes: (mode === 'rebuild' ? entries : added).reduce((n, e) => n + bytes(e.text), 0) };
|
|
51
|
+
}
|
|
52
|
+
/** Builds the staging memory `open` gives from the plan, `entry(i)` for each of its `total` messages, and calls `activate` once its
|
|
53
|
+
* whole tree is built and it is closed. It continues from what the memory already logged, so a paused or crashed import resumes. A
|
|
54
|
+
* 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;
|
|
55
|
+
* `entry` is read as needed, so a host need not hold the plan in RAM.
|
|
56
|
+
* The live memory must take no messages from `planImport` until `activate`, or the switch drops them: queue them and log them after. */
|
|
57
|
+
export async function runImport({ entry, total, open, activate }, signal) {
|
|
58
|
+
const planned = (i) => {
|
|
59
|
+
const e = entry(i);
|
|
60
|
+
if (!isEntry(e) || e.i !== i)
|
|
61
|
+
throw new Error('Import staging data is invalid; original memory remains intact.');
|
|
62
|
+
return e;
|
|
63
|
+
};
|
|
64
|
+
if (!Number.isSafeInteger(total) || total < 0)
|
|
65
|
+
throw new Error('Import staging data is invalid; original memory remains intact.');
|
|
66
|
+
const memory = await open();
|
|
67
|
+
try {
|
|
68
|
+
if (memory.root.length > total || memory.root.some((_, i) => memory.text(i) !== planned(i).text))
|
|
69
|
+
throw new Error('Import staging data does not match its log; original memory remains intact.');
|
|
70
|
+
// Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
|
|
71
|
+
// messages is, so up to `jobs` summaries run at once. Unlike a turn, an import waits through failures (`lastError` says why).
|
|
72
|
+
for (let i = memory.root.length; i < total; i++) {
|
|
73
|
+
await memory.settle(signal, 'ahead');
|
|
74
|
+
const e = planned(i);
|
|
75
|
+
memory.append(e.kind, e.text, e.date, e.receipt, e.origin);
|
|
76
|
+
}
|
|
77
|
+
await memory.settle(signal, 'tree');
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
// Close all writers before the switch. The previous memory stays intact.
|
|
80
|
+
await memory.close();
|
|
81
|
+
// A store that writes behind reports a failed write only here: switching would drop the messages it lost.
|
|
82
|
+
if (memory.saveError !== undefined)
|
|
83
|
+
throw new Error(`Import could not be saved: ${memory.saveError}; original memory remains intact.`);
|
|
84
|
+
signal.throwIfAborted();
|
|
85
|
+
await activate();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await memory.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -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';
|
|
@@ -0,0 +1,12 @@
|
|
|
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 } from './memory.js';
|
|
5
|
+
export { isMime } from './store.js';
|
|
6
|
+
export { ACCEPTED_BYTES, createCompressor } from './compactor.js';
|
|
7
|
+
export { cachePayload } from './cache.js';
|
|
8
|
+
export { allowSearch, COMPACT, CONTINUITY, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.js';
|
|
9
|
+
export { boundedMessage, completedExchange, logMessage, refreshBudget, refreshedRun, refreshPoint, textContent, withView } from './messages.js';
|
|
10
|
+
export { memoryTools, searchTool } from './tools.js';
|
|
11
|
+
export { decode, isImage, loadImages } from './images.js';
|
|
12
|
+
export { chronological, deduplicate, planImport, runImport } from './import.js';
|