pi-optchat 0.11.1 → 0.11.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/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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { defineExtension, defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable';
|
|
2
|
+
import { chronological, deduplicate, isEntry, runImport } from '../core/index.js';
|
|
3
|
+
import { attachment } from './optchat.js';
|
|
4
|
+
import { importTables } from './store.js';
|
|
5
|
+
/** How many new summaries the import builds between the progress it saves. Pi Durable parks a task that restarts 5 times without
|
|
6
|
+
* saving progress, which a long rebuild could otherwise do over a few deploys. */
|
|
7
|
+
const CHECKPOINT = 50;
|
|
8
|
+
/** Imports history into a Pi Durable agent's memory: append adds it after the log, rebuild orders everything by date and summarizes
|
|
9
|
+
* it all again. The import builds a new generation beside the live one, in a Pi Durable task that resumes after an eviction, while
|
|
10
|
+
* the chat goes on with the old one; it switches in one transaction once the new one is complete. Install `extension` beside
|
|
11
|
+
* `optchat()`'s and `attach` the opened Harness. Neither the plan nor the log's texts are held in RAM. */
|
|
12
|
+
export function optchatImport({ memory, db, read, open, onActivated }) {
|
|
13
|
+
const attached = attachment('OptChat import'), tables = importTables(db, read);
|
|
14
|
+
/** The generation `memory` was opened at, read when first attached: once an import goes live, `memory` is the old one's until
|
|
15
|
+
* the object restarts. */
|
|
16
|
+
let opened;
|
|
17
|
+
/** The live tasks building `generation`'s import. */
|
|
18
|
+
const tasks = async (generation) => (await attached.get().harness.inspect(attached.get().context)).tasks
|
|
19
|
+
.filter(({ record: { kind, input } }) => kind === Import.definition.name && typeof input === 'object' && input !== null && !Array.isArray(input) && input.generation === generation);
|
|
20
|
+
const createTask = (generation) => attached.get().harness.commit(tx => tx.createTask(Import, { generation }, { ownership: { kind: 'conversation' }, conversationId: ROOT_CONVERSATION_ID, background: true }), attached.get().context);
|
|
21
|
+
const Import = defineTask({
|
|
22
|
+
name: 'optchat.import', version: 1,
|
|
23
|
+
initial: () => ({ phase: 'import', done: 0 }),
|
|
24
|
+
phases: {
|
|
25
|
+
// Cut off by an eviction, the phase runs again from the top, and `runImport` carries on from the new generation's log.
|
|
26
|
+
import: async (task, runtime, ctx) => {
|
|
27
|
+
const { job } = await tables.state();
|
|
28
|
+
let activated = false, saving = Promise.resolve();
|
|
29
|
+
// A task whose import was discarded, or went live, has nothing left to do.
|
|
30
|
+
if (job?.generation === task.input.generation)
|
|
31
|
+
await runImport({ entry: tables.planned, total: job.total, activate: async () => { await tables.activate(job.generation); activated = true; },
|
|
32
|
+
open: async () => {
|
|
33
|
+
const building = await open(job.generation);
|
|
34
|
+
// From the progress last saved, so what earlier runs built before they were cut off counts too.
|
|
35
|
+
let done = task.state.checkpoint.done;
|
|
36
|
+
const checkpoint = () => {
|
|
37
|
+
if (building.tree.size < done + CHECKPOINT)
|
|
38
|
+
return;
|
|
39
|
+
done = building.tree.size;
|
|
40
|
+
saving = saving.then(() => runtime.commit(() => ({ status: 'running', checkpoint: { phase: 'import', done } }), ctx)).catch(() => { });
|
|
41
|
+
};
|
|
42
|
+
building.onChange(checkpoint);
|
|
43
|
+
checkpoint();
|
|
44
|
+
return building;
|
|
45
|
+
} }, runtime.signal);
|
|
46
|
+
await saving;
|
|
47
|
+
// Once the switch is saved the old memory must go, even if this commit fails: nothing would call it later.
|
|
48
|
+
try {
|
|
49
|
+
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }), ctx);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (activated)
|
|
53
|
+
onActivated();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
extension: defineExtension({ name: 'optchat.import', tasks: [Import] }),
|
|
61
|
+
/** Runs a pending import whose task is gone: it failed, or the object died before creating it. Returns a function that stops. */
|
|
62
|
+
attach(harness, ctx) {
|
|
63
|
+
attached.set(harness, ctx);
|
|
64
|
+
opened ??= tables.state().then(state => state.live);
|
|
65
|
+
void (async () => { const { job } = await tables.state(); if (job && !(await tasks(job.generation)).length)
|
|
66
|
+
await createTask(job.generation); })()
|
|
67
|
+
.catch(error => console.error(`OptChat could not resume the import: ${error instanceof Error ? error.message : String(error)}`));
|
|
68
|
+
return attached.clear;
|
|
69
|
+
},
|
|
70
|
+
/** Plans an import of `incoming` and starts building it, with its counts; undefined when nothing in it is new. Refused for an
|
|
71
|
+
* invalid entry, while another is pending, once memory failed a save, or after a switch until the object restarts. */
|
|
72
|
+
async start(incoming, mode) {
|
|
73
|
+
attached.get();
|
|
74
|
+
if (!opened)
|
|
75
|
+
throw new Error('OptChat import is not attached to a Harness yet.');
|
|
76
|
+
const live = await opened;
|
|
77
|
+
// Up front: a plan that fails later would stay pending, blocking every import after it.
|
|
78
|
+
if (!incoming.every(e => isEntry({ ...e, i: 0 })))
|
|
79
|
+
throw new Error('Imported entry is invalid.');
|
|
80
|
+
if (memory.saveError)
|
|
81
|
+
throw new Error(`Memory could not be saved: ${memory.saveError}; restart before importing.`);
|
|
82
|
+
// Planned from metadata; each text is read from memory as its plan row is written.
|
|
83
|
+
const { added, skipped } = deduplicate(memory.root, incoming);
|
|
84
|
+
if (!added.length)
|
|
85
|
+
return undefined;
|
|
86
|
+
const all = mode === 'rebuild' ? chronological([...memory.root, ...added]) : [...memory.root, ...chronological(added)];
|
|
87
|
+
const plan = function* () { for (const [i, e] of all.entries())
|
|
88
|
+
yield 'text' in e ? { ...e, i } : { ...e, i, text: memory.text(e.i) }; };
|
|
89
|
+
const job = await tables.stage(live, mode, plan(), mode === 'append' ? memory.root.length : 0);
|
|
90
|
+
await createTask(job.generation);
|
|
91
|
+
return { ...job, added: added.length, skipped };
|
|
92
|
+
},
|
|
93
|
+
/** The pending import, if any, with its task's state: running, ready, or blocked (parked after restarts: `harness.retryTask`).
|
|
94
|
+
* No task means it failed; `attach` starts it again at the next restart. */
|
|
95
|
+
async pending() {
|
|
96
|
+
const { job } = await tables.state();
|
|
97
|
+
return job && { ...job, task: (await tasks(job.generation))[0]?.state };
|
|
98
|
+
},
|
|
99
|
+
/** Stops the pending import; the live memory is left as it is. One started meanwhile is left alone. */
|
|
100
|
+
async discard() {
|
|
101
|
+
const { job } = await tables.state();
|
|
102
|
+
if (!job)
|
|
103
|
+
return;
|
|
104
|
+
for (const task of await tasks(job.generation))
|
|
105
|
+
await attached.get().harness.abortTask(task.record.id, attached.get().context);
|
|
106
|
+
await tables.discard(job.generation);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** OptChat's memory on Pi Durable, for a Cloudflare Durable Object or any other Pi Durable host: a Store over SQLite, the extension
|
|
2
|
+
* that builds each request from the view, imports, and cache marks for its models. Build the memory with `pi-optchat/core`. */
|
|
3
|
+
export { SqliteStore, type Read, type SqliteStoreOptions } from './store.ts';
|
|
4
|
+
export { optchat, withCacheMarks, type OptChatOptions } from './optchat.ts';
|
|
5
|
+
export { optchatImport, type ImportJob, type ImportOptions } from './import.ts';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** OptChat's memory on Pi Durable, for a Cloudflare Durable Object or any other Pi Durable host: a Store over SQLite, the extension
|
|
2
|
+
* that builds each request from the view, imports, and cache marks for its models. Build the memory with `pi-optchat/core`. */
|
|
3
|
+
export { SqliteStore } from './store.js';
|
|
4
|
+
export { optchat, withCacheMarks } from './optchat.js';
|
|
5
|
+
export { optchatImport } from './import.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ConversationId, type Harness, type HarnessOptions } from '@earendil-works/pi-durable';
|
|
2
|
+
import { type Memory } from '../core/index.ts';
|
|
3
|
+
export type Context = Parameters<Harness['commit']>[1];
|
|
4
|
+
type Models = Exclude<HarnessOptions['models'], () => unknown>;
|
|
5
|
+
export interface OptChatOptions {
|
|
6
|
+
memory: Memory;
|
|
7
|
+
/** The conversation OptChat remembers; Pi Durable's root by default. Other conversations that select the extension are left alone. */
|
|
8
|
+
conversation?: ConversationId;
|
|
9
|
+
/** The memory part of the system prompt: VIEW_DOC (plus CONTINUITY and SEARCH_DOC as set) by default. */
|
|
10
|
+
prompt?: string;
|
|
11
|
+
/** Offers the `search` tool. Off by default, as in Pi. */
|
|
12
|
+
search?: boolean;
|
|
13
|
+
/** The previous exchange is sent in full after the view when it is at most this many bytes; 0 never sends it. */
|
|
14
|
+
previousExchangeBytes?: number;
|
|
15
|
+
/** When a run's messages since the last refresh pass this share of the model's window, they move into memory and the run goes
|
|
16
|
+
* on with a fresh view, as Pi's Turn refresh threshold (#146). 0.3 by default; 0 is off. */
|
|
17
|
+
refreshAt?: number;
|
|
18
|
+
}
|
|
19
|
+
/** OptChat's memory for one Pi Durable conversation. Install `extension` in the registry Harness.open gets, then `attach` the opened
|
|
20
|
+
* Harness. Each request is built as OptChat builds it in Pi: the view, the previous exchange and the current context, so the model
|
|
21
|
+
* never sees Pi's older transcript. When the conversation goes idle, OptChat resets Pi's context and a background task logs what the
|
|
22
|
+
* reset ended into memory and builds its summaries; Pi Durable runs that task, and wakes the Durable Object for it after an eviction.
|
|
23
|
+
* Turn Pi's own compaction off (`settings.compaction.enabled: false`); the extension also declines any that starts. */
|
|
24
|
+
export declare function optchat({ memory, conversation: id, prompt, search, previousExchangeBytes, refreshAt }: OptChatOptions): {
|
|
25
|
+
extension: import("@earendil-works/pi-durable").Extension<import("@earendil-works/pi-durable").ToolRegistration>;
|
|
26
|
+
/** Starts following the conversation, and resets it now if a run ended before a restart. Returns a function that stops. */
|
|
27
|
+
attach(harness: Harness, ctx: Context): () => void;
|
|
28
|
+
};
|
|
29
|
+
/** The Harness and context an extension's `attach` was given; `get` throws before it is attached, or after `clear`. */
|
|
30
|
+
export declare function attachment(name: string): {
|
|
31
|
+
set: (harness: Harness, context: Context) => void;
|
|
32
|
+
clear: () => void;
|
|
33
|
+
get: () => {
|
|
34
|
+
harness: Harness;
|
|
35
|
+
context: Context;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/** Pi Durable's `Models` with OptChat's cache marks on Anthropic requests that carry a view: in blocks of 4 lines, so a view that
|
|
39
|
+
* grows at its end is read from the cache (pi-ai's own marks miss it). A request that sets its own `onPayload` is left alone, as
|
|
40
|
+
* the compactor's are. A Proxy, since `createModels()` returns a class instance whose methods a spread would lose. */
|
|
41
|
+
export declare function withCacheMarks<M extends Models>(models: M): M;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { AssistantEntry, CompactionTask, defineExtension, defineTask, defineTool, GenerationTask, hook, InboxDoc, LiveDoc, ResetEntry, ROOT_CONVERSATION_ID, section, ToolResultEntry, UserEntry, } from '@earendil-works/pi-durable';
|
|
2
|
+
import { allowSearch, boundedMessage, cachePayload, completedExchange, CONTINUITY, decode, isImage, isMime, logMessage, memoryTools, refreshBudget, refreshedRun, refreshPoint, SEARCH_DOC, searchTool, textContent, TURN_REFRESHED, VIEW_DOC, withView, } from '../core/index.js';
|
|
3
|
+
/** The entries that are the chat: other kinds (system prompt changes, resets, compactions, other extensions' own) stay out of memory. */
|
|
4
|
+
const CHAT = new Set([UserEntry.kind, AssistantEntry.kind, ToolResultEntry.kind]);
|
|
5
|
+
const chat = (entry) => CHAT.has(entry.kind) ? entry.model ?? [] : [];
|
|
6
|
+
/** What memory logs of an entry: its chat, or a reset's note (`Conversation.reset(text)`, a tool's handoff) marked as one. Pi sends
|
|
7
|
+
* the note in the context it starts, which is logged only once it ends, so the model never sees it twice. */
|
|
8
|
+
const remembered = (entry) => !ResetEntry.is(entry) ? chat(entry)
|
|
9
|
+
: (entry.model ?? []).map(note => ({ role: 'user', content: `[Handoff note] ${textContent(note.content)}`, timestamp: note.timestamp }));
|
|
10
|
+
/** Each message OptChat logs from a Pi entry carries the entry's id, so the log itself says how far it has read Pi's transcript. */
|
|
11
|
+
const RECEIPT = 'pi:';
|
|
12
|
+
/** The answer that ends a run, whether it succeeded, failed or was aborted. */
|
|
13
|
+
const endsRun = (m) => m.role === 'assistant' && m.stopReason !== 'toolUse';
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
/** The images memory names, as Pi keeps them: a user's and a tool's, but not zoom's, which are kept already. */
|
|
16
|
+
const keptImages = (message) => message.role === 'user' || message.role === 'toolResult' && !('toolName' in message && message.toolName === 'zoom')
|
|
17
|
+
? Array.isArray(message.content) ? message.content.filter(isImage) : [] : [];
|
|
18
|
+
/** Each distinct image by its base64, named as Pi's OptChat names it: the first 16 hex digits of the SHA-256 of its base64. Web
|
|
19
|
+
* Crypto is asynchronous and core's ImageRef is not, so images are named before core reads the names. */
|
|
20
|
+
async function imageNames(images) {
|
|
21
|
+
const distinct = new Map(images.map(image => [image.data, image]));
|
|
22
|
+
return new Map(await Promise.all([...distinct.values()].map(async (image) => {
|
|
23
|
+
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(image.data)), 0, 8);
|
|
24
|
+
return [image.data, { image, name: Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('') }];
|
|
25
|
+
})));
|
|
26
|
+
}
|
|
27
|
+
/** One of core's memory tools as a Pi Durable tool, its arguments typed by its own schema. */
|
|
28
|
+
const durableTool = (tool) => defineTool({
|
|
29
|
+
name: tool.name, description: tool.description, parameters: tool.parameters, replay: 'safe',
|
|
30
|
+
execute: async (args, api) => ({ output: (await tool.execute(api.callId, args)).content }),
|
|
31
|
+
});
|
|
32
|
+
/** OptChat's memory for one Pi Durable conversation. Install `extension` in the registry Harness.open gets, then `attach` the opened
|
|
33
|
+
* Harness. Each request is built as OptChat builds it in Pi: the view, the previous exchange and the current context, so the model
|
|
34
|
+
* never sees Pi's older transcript. When the conversation goes idle, OptChat resets Pi's context and a background task logs what the
|
|
35
|
+
* reset ended into memory and builds its summaries; Pi Durable runs that task, and wakes the Durable Object for it after an eviction.
|
|
36
|
+
* Turn Pi's own compaction off (`settings.compaction.enabled: false`); the extension also declines any that starts. */
|
|
37
|
+
export function optchat({ memory, conversation: id = ROOT_CONVERSATION_ID, prompt, search = false, previousExchangeBytes = 16_000, refreshAt = 0.3 }) {
|
|
38
|
+
const attached = attachment('OptChat'), opened = attached.get;
|
|
39
|
+
/** The Pi entry logging resumes after: messages before the context's head are logged, the rest are sent as they are. On open it is
|
|
40
|
+
* the one before the newest entry in memory, which is read again: a restart may have cut it off between its records (an answer
|
|
41
|
+
* with tool calls makes several), so the first `saved` records it makes, which memory has, are skipped. The newest is the highest
|
|
42
|
+
* id, not the last in the log: a rebuild orders turns by their dates, which a clock can put out of order. */
|
|
43
|
+
let logged = 0, saved = 0;
|
|
44
|
+
const newest = memory.root.reduce((max, e) => e.receipt?.startsWith(RECEIPT) ? Math.max(max, Number(e.receipt.slice(RECEIPT.length))) : max, 0);
|
|
45
|
+
if (newest) {
|
|
46
|
+
logged = newest - 1;
|
|
47
|
+
saved = memory.root.filter(e => e.receipt === `${RECEIPT}${newest}`).length;
|
|
48
|
+
}
|
|
49
|
+
const resumed = new Proxy(memory, {
|
|
50
|
+
get: (target, key) => key !== 'append' ? Reflect.get(target, key) : (...args) => saved > 0 ? void saved-- : target.append(...args),
|
|
51
|
+
});
|
|
52
|
+
let logging = Promise.resolve();
|
|
53
|
+
/** What a context's requests share, fixed when its first one is built so they read the same cached prefix. */
|
|
54
|
+
let turn;
|
|
55
|
+
const handle = async (ctx) => {
|
|
56
|
+
const conversation = await opened().harness.conversation(id, ctx);
|
|
57
|
+
if (!conversation)
|
|
58
|
+
throw new Error(`No conversation ${id}.`);
|
|
59
|
+
return conversation;
|
|
60
|
+
};
|
|
61
|
+
/** Logs Pi's entries before `head` that memory doesn't have yet, one caller at a time. A call that fails (aborted, say) leaves
|
|
62
|
+
* the next one to carry on from the last entry it logged. */
|
|
63
|
+
const logUpTo = (conversation, head, ctx) => {
|
|
64
|
+
const run = logging.then(async () => {
|
|
65
|
+
let cursor;
|
|
66
|
+
do {
|
|
67
|
+
if (logged + 1 > head - 1)
|
|
68
|
+
return;
|
|
69
|
+
const page = await conversation.entries({ order: 'ascending', minEntryId: logged + 1, maxEntryId: head - 1 }, 256, cursor, ctx);
|
|
70
|
+
for (const entry of page.items) {
|
|
71
|
+
const messages = remembered(entry), names = await imageNames(messages.flatMap(keptImages));
|
|
72
|
+
// Kept before the message is logged, so the image its text names is there for zoom; one that can't be kept is only named.
|
|
73
|
+
// An image sent again, or read again after a restart, is written again under its name.
|
|
74
|
+
for (const { image, name } of names.values())
|
|
75
|
+
if (isMime(image.mimeType)) {
|
|
76
|
+
try {
|
|
77
|
+
await memory.store.putImage(name, image.mimeType, decode(image.data));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error(`OptChat could not keep an image: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Once a save failed (also one queued before, failing during the awaits above), memory refuses appends: requests keep the
|
|
84
|
+
// view it has, and a restart logs the rest from Pi's transcript.
|
|
85
|
+
if (memory.saveError)
|
|
86
|
+
return;
|
|
87
|
+
for (const message of messages)
|
|
88
|
+
logMessage(resumed, message, { receipt: `${RECEIPT}${entry.id}`, imageRef: image => `[image ${names.get(image.data)?.name}]` });
|
|
89
|
+
logged = entry.id;
|
|
90
|
+
}
|
|
91
|
+
cursor = page.next;
|
|
92
|
+
} while (cursor);
|
|
93
|
+
});
|
|
94
|
+
logging = run.catch(() => { });
|
|
95
|
+
return run;
|
|
96
|
+
};
|
|
97
|
+
/** The last run before `head`, from Pi's transcript, if it was answered and is short enough to send whole. It is read from the
|
|
98
|
+
* entries, not the context's messages, which leave failed and aborted answers out and so would join a run to the one before.
|
|
99
|
+
* A text-only answer ends a run here even when steering followed it in the same Pi run. */
|
|
100
|
+
const previousBefore = async (conversation, head, ctx) => {
|
|
101
|
+
if (!previousExchangeBytes)
|
|
102
|
+
return [];
|
|
103
|
+
const [last] = (await conversation.entries({ order: 'descending', maxEntryId: head - 1 }, 1, undefined, ctx)).items;
|
|
104
|
+
if (!last)
|
|
105
|
+
return [];
|
|
106
|
+
const messages = (await conversation.context(ctx, { at: last.id })).entries.flatMap(chat);
|
|
107
|
+
const start = messages.slice(0, messages.findLastIndex(m => m.role === 'user')).findLastIndex(endsRun) + 1;
|
|
108
|
+
const run = messages.slice(start), names = await imageNames(run.filter(m => m.role === 'user').flatMap(keptImages));
|
|
109
|
+
const exchange = completedExchange(run, image => `[image ${names.get(image.data)?.name}]`);
|
|
110
|
+
const bytes = exchange.reduce((sum, m) => sum + encoder.encode(textContent(m.content)).length, 0);
|
|
111
|
+
return bytes <= previousExchangeBytes ? exchange : [];
|
|
112
|
+
};
|
|
113
|
+
const beforeRequest = async ({ messages }, api, ctx) => {
|
|
114
|
+
if (api.conversationId !== id)
|
|
115
|
+
return undefined;
|
|
116
|
+
const conversation = await handle(ctx);
|
|
117
|
+
const { head, entries } = await conversation.context(ctx);
|
|
118
|
+
if (!turn || turn.head !== head?.id) {
|
|
119
|
+
if (head)
|
|
120
|
+
await logUpTo(conversation, head.id, ctx);
|
|
121
|
+
await memory.settle(ctx.abortSignal);
|
|
122
|
+
turn = { head: head?.id, view: memory.render(), previous: head ? await previousBefore(conversation, head.id, ctx) : [] };
|
|
123
|
+
}
|
|
124
|
+
const at = messages.findIndex(m => m.role !== 'system');
|
|
125
|
+
if (at < 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
// A tool result over 30K characters is sent as its head and tail, as in Pi.
|
|
128
|
+
const run = messages.slice(at).map(boundedMessage), shared = turn;
|
|
129
|
+
// `run` is the whole context, which can hold more than one run: inputs queued with no idle reset between them, or a reset's note
|
|
130
|
+
// first. The run being answered starts at its task, the last of the user messages after the newest finished answer; a refresh
|
|
131
|
+
// moves what comes before it into memory with the steps it cuts, and a steer before the cut too, as in Pi.
|
|
132
|
+
let task = run.findLastIndex(endsRun) + 1;
|
|
133
|
+
while (run[task + 1]?.role === 'user')
|
|
134
|
+
task++;
|
|
135
|
+
const own = run.slice(task);
|
|
136
|
+
/** The run's answers and their entries. Pi Durable's messages are copies of its entries', so an answer is found by its first
|
|
137
|
+
* tool call's id, or its timestamp without one. */
|
|
138
|
+
const answer = (m) => m.role === 'assistant' ? m.content.find(c => c.type === 'toolCall')?.id ?? m.timestamp : undefined;
|
|
139
|
+
const steps = entries.flatMap(entry => {
|
|
140
|
+
const sent = AssistantEntry.is(entry) && entry.model?.[0];
|
|
141
|
+
const i = sent ? own.findIndex(m => answer(m) === answer(sent)) : -1;
|
|
142
|
+
return i > 0 ? [{ entry, i }] : [];
|
|
143
|
+
});
|
|
144
|
+
/** Logs Pi's entries before `end`, rebuilding the view if that logged anything. False once a save failed: memory then lacks
|
|
145
|
+
* part of the run, so it is sent whole. */
|
|
146
|
+
const logThrough = async (end) => {
|
|
147
|
+
const count = memory.root.length;
|
|
148
|
+
await logUpTo(conversation, end, ctx);
|
|
149
|
+
if (memory.saveError)
|
|
150
|
+
return false;
|
|
151
|
+
if (memory.root.length !== count) {
|
|
152
|
+
await memory.settle(ctx.abortSignal);
|
|
153
|
+
shared.view = memory.render();
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
// Once a refresh has logged part of this context, an earlier run in it that has ended goes into memory whole, as the reset it
|
|
158
|
+
// missed would have done: a run queued behind a long one doesn't send it again.
|
|
159
|
+
const ended = task > 0 && logged > (head?.id ?? 0) && run[task - 1].role === 'assistant'
|
|
160
|
+
? entries.find(entry => AssistantEntry.is(entry) && entry.model?.[0] && answer(entry.model[0]) === answer(run[task - 1])) : undefined;
|
|
161
|
+
const whole = ended && await logThrough(ended.id + 1) ? own : run;
|
|
162
|
+
// A refresh logs the run up to the answer it cuts at, so once any of its answers is in the log, the run is sent from the first
|
|
163
|
+
// answer after it, after a restart too; a restart in the middle of that logging leaves the rest of it to do.
|
|
164
|
+
let keep = 0;
|
|
165
|
+
const kept = steps.some(s => s.entry.id <= logged) ? steps.find(s => s.entry.id > logged) : undefined;
|
|
166
|
+
if (kept && await logThrough(kept.entry.id))
|
|
167
|
+
keep = kept.i;
|
|
168
|
+
const { model } = await conversation.agent(ctx);
|
|
169
|
+
const refresh = !memory.saveError && refreshPoint(own, keep, refreshBudget(model && api.models.getModel(model.provider, model.modelId)?.contextWindow, refreshAt));
|
|
170
|
+
const cut = refresh && steps.find(s => s.i === refresh.cut);
|
|
171
|
+
if (cut && await logThrough(cut.entry.id))
|
|
172
|
+
keep = cut.i;
|
|
173
|
+
const sent = keep ? refreshedRun(own, keep, TURN_REFRESHED) : whole;
|
|
174
|
+
return { messages: withView([...messages.slice(0, at), ...turn.previous, ...sent], turn.view) };
|
|
175
|
+
};
|
|
176
|
+
const Remember = defineTask({
|
|
177
|
+
name: 'optchat.remember', version: 1,
|
|
178
|
+
initial: () => ({ phase: 'remember' }),
|
|
179
|
+
phases: {
|
|
180
|
+
// Logs what the newest reset ended and waits until the next turn's view is built, or every summary owed has failed. A phase
|
|
181
|
+
// cut off by an eviction runs again from the top: logging skips what is in memory, and memory rebuilds what it owes on open.
|
|
182
|
+
remember: async (_task, runtime, ctx) => {
|
|
183
|
+
const conversation = await handle(ctx), { head } = await conversation.context(ctx);
|
|
184
|
+
if (head)
|
|
185
|
+
await logUpTo(conversation, head.id, ctx);
|
|
186
|
+
await memory.settle(runtime.signal);
|
|
187
|
+
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }), ctx);
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
|
191
|
+
});
|
|
192
|
+
/** Once the conversation is idle with a chat in its context, resets the context and starts remembering it, in one commit. */
|
|
193
|
+
const resetIfIdle = async () => {
|
|
194
|
+
const { harness, context } = opened();
|
|
195
|
+
const busy = async () => !!(await harness.snapshot(LiveDoc, id, context))?.run || !!(await harness.snapshot(InboxDoc, id, context))?.items.length;
|
|
196
|
+
if (await busy() || !(await (await handle(context)).context(context)).entries.some(e => chat(e).length))
|
|
197
|
+
return;
|
|
198
|
+
await harness.commit(async (tx) => {
|
|
199
|
+
// Checked again where it is atomic: an input that came in meanwhile keeps its context.
|
|
200
|
+
if ((await tx.doc(LiveDoc, id)).run || (await tx.doc(InboxDoc, id)).items.length)
|
|
201
|
+
return;
|
|
202
|
+
await tx.appendEntry(id, { kind: ResetEntry.kind, head: 'self' });
|
|
203
|
+
await tx.createTask(Remember, {}, { ownership: { kind: 'conversation' }, conversationId: id, background: true });
|
|
204
|
+
}, context);
|
|
205
|
+
};
|
|
206
|
+
/** Checks after commits, one check at a time; commits during a check ask for one more, however many there were. */
|
|
207
|
+
let checking = false, again = false;
|
|
208
|
+
const schedule = async () => {
|
|
209
|
+
again = true;
|
|
210
|
+
if (checking)
|
|
211
|
+
return;
|
|
212
|
+
checking = true;
|
|
213
|
+
while (again) {
|
|
214
|
+
again = false;
|
|
215
|
+
await resetIfIdle().catch(error => console.error(`OptChat could not reset the context: ${error instanceof Error ? error.message : String(error)}`));
|
|
216
|
+
}
|
|
217
|
+
checking = false;
|
|
218
|
+
};
|
|
219
|
+
const [zoom, date] = memoryTools(() => memory);
|
|
220
|
+
const tools = [durableTool(zoom), durableTool(date), ...search ? [durableTool(searchTool(() => memory))] : []];
|
|
221
|
+
const text = prompt ?? allowSearch(`${VIEW_DOC}${previousExchangeBytes ? CONTINUITY : ''}${search ? SEARCH_DOC : ''}`, search);
|
|
222
|
+
return {
|
|
223
|
+
extension: defineExtension({
|
|
224
|
+
name: 'optchat',
|
|
225
|
+
tools,
|
|
226
|
+
sections: [section('optchat', () => text, { tag: false })],
|
|
227
|
+
hooks: [hook(GenerationTask, { beforeRequest }), hook(CompactionTask, { beforeCompact: (_compaction, api) => api.conversationId === id ? { decline: true } : undefined })],
|
|
228
|
+
tasks: [Remember],
|
|
229
|
+
}),
|
|
230
|
+
/** Starts following the conversation, and resets it now if a run ended before a restart. Returns a function that stops. */
|
|
231
|
+
attach(harness, ctx) {
|
|
232
|
+
attached.set(harness, ctx);
|
|
233
|
+
const stop = harness.subscribeCommits(() => void schedule());
|
|
234
|
+
void schedule();
|
|
235
|
+
return stop;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** The Harness and context an extension's `attach` was given; `get` throws before it is attached, or after `clear`. */
|
|
240
|
+
export function attachment(name) {
|
|
241
|
+
let at;
|
|
242
|
+
return {
|
|
243
|
+
set: (harness, context) => { at = { harness, context }; },
|
|
244
|
+
clear: () => { at = undefined; },
|
|
245
|
+
get: () => { if (!at)
|
|
246
|
+
throw new Error(`${name} is not attached to a Harness yet.`); return at; },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Pi Durable's `Models` with OptChat's cache marks on Anthropic requests that carry a view: in blocks of 4 lines, so a view that
|
|
250
|
+
* grows at its end is read from the cache (pi-ai's own marks miss it). A request that sets its own `onPayload` is left alone, as
|
|
251
|
+
* the compactor's are. A Proxy, since `createModels()` returns a class instance whose methods a spread would lose. */
|
|
252
|
+
export function withCacheMarks(models) {
|
|
253
|
+
return new Proxy(models, {
|
|
254
|
+
get(target, key) {
|
|
255
|
+
const value = Reflect.get(target, key, target);
|
|
256
|
+
if (key !== 'streamSimple')
|
|
257
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
258
|
+
const stream = (model, request, options) => target.streamSimple(model, request, options?.onPayload || model.api !== 'anthropic-messages' ? options : { ...options, onPayload: payload => cachePayload(payload) });
|
|
259
|
+
return stream;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SqliteDatabase, SqliteValue } from '@earendil-works/pi-durable/storage/sqlite';
|
|
2
|
+
import { type Entry, type ImageStore, type ImportMode, type Mime, type Part, type Saved, type Store, type Summary } from '../core/index.ts';
|
|
3
|
+
export interface SqliteStoreOptions {
|
|
4
|
+
/** Where images are kept instead of `optchat_images`, such as an R2 bucket that keeps large binary data out of the database
|
|
5
|
+
* (its row size limit and storage cost). */
|
|
6
|
+
images?: ImageStore;
|
|
7
|
+
/** The generation to open, as `optchatImport` does for the one it builds. Default: the live one, whose load also deletes the
|
|
8
|
+
* rows of generations that are neither live nor being imported. */
|
|
9
|
+
generation?: number;
|
|
10
|
+
}
|
|
11
|
+
/** A synchronous query on the same database, for the texts memory reads while it builds a view, zooms or searches. In a Durable
|
|
12
|
+
* Object: `(sql, ...params) => ctx.storage.sql.exec(sql, ...params)`; on Node, node:sqlite's `(sql, ...params) => db.prepare(sql).iterate(...params)`. */
|
|
13
|
+
export type Read = (sql: string, ...params: SqliteValue[]) => Iterable<Record<string, unknown>>;
|
|
14
|
+
/** OptChat's memory in any Chord `SqliteDatabase`, such as Node's (`NodeSqliteDatabase`) or a Durable Object's. Give it the
|
|
15
|
+
* database Pi Durable's storage uses, so both share one queue: in a Durable Object, the `DurableObjectSqliteDatabase` over
|
|
16
|
+
* `ctx.storage` (direct `ctx.storage.sql` writes could be rolled back with a Pi transaction). `read` reads texts from the same
|
|
17
|
+
* database synchronously; it only reads rows whose writes have finished, so it needs no queue. Its tables are `optchat_*`, beside
|
|
18
|
+
* Pi's `pi_*`. Memory loads only metadata and node sizes; texts stay here. Messages are split so no row passes a Durable Object's
|
|
19
|
+
* 2 MB limit; images go to `images` when given, otherwise to `optchat_images`, one row each. */
|
|
20
|
+
export declare class SqliteStore implements Store {
|
|
21
|
+
#private;
|
|
22
|
+
private readonly db;
|
|
23
|
+
private readonly read;
|
|
24
|
+
private readonly options;
|
|
25
|
+
constructor(db: SqliteDatabase, read: Read, options?: SqliteStoreOptions);
|
|
26
|
+
load(): Promise<Saved>;
|
|
27
|
+
text(i: number): string;
|
|
28
|
+
summary({ l, i }: Part): string;
|
|
29
|
+
/** The primary key refuses a second message at an index, so a store that was not the last to append writes nothing. */
|
|
30
|
+
append(entry: Entry): Promise<void>;
|
|
31
|
+
appendNode(node: Summary): Promise<void>;
|
|
32
|
+
saveView(view: string): Promise<void>;
|
|
33
|
+
putImage(name: string, mimeType: Mime, data: Uint8Array): Promise<void>;
|
|
34
|
+
image(name: string): Promise<{
|
|
35
|
+
mimeType: Mime;
|
|
36
|
+
data: Uint8Array;
|
|
37
|
+
} | undefined>;
|
|
38
|
+
}
|
|
39
|
+
/** A pending import: the generation it builds, and its plan's mode and length. */
|
|
40
|
+
export interface ImportJob {
|
|
41
|
+
generation: number;
|
|
42
|
+
mode: ImportMode;
|
|
43
|
+
total: number;
|
|
44
|
+
}
|
|
45
|
+
/** The rows `optchatImport` keeps: which generation is live, the pending import and its plan. */
|
|
46
|
+
export declare function importTables(db: SqliteDatabase, read: Read): {
|
|
47
|
+
state: () => Promise<{
|
|
48
|
+
live: number;
|
|
49
|
+
job: ImportJob | undefined;
|
|
50
|
+
}>;
|
|
51
|
+
/** Saves the plan, `entries` in order, made from generation `from`, and makes it the pending import of a new generation. A
|
|
52
|
+
* generation is never handed out twice, so a late call about a discarded one can't touch the next. Append's new generation starts with the live one's first `kept` messages, the nodes
|
|
53
|
+
* over them, and its view. Refused unless `from` is still live: an import went live since. */
|
|
54
|
+
stage: (from: number, mode: ImportMode, entries: Iterable<Entry>, kept: number) => Promise<{
|
|
55
|
+
generation: number;
|
|
56
|
+
mode: ImportMode;
|
|
57
|
+
total: number;
|
|
58
|
+
}>;
|
|
59
|
+
/** Planned message `i`, as saved; validated where it is used. */
|
|
60
|
+
planned(i: number): unknown;
|
|
61
|
+
/** Makes `generation` live and drops its plan, in one transaction; refused if its import was discarded meanwhile. */
|
|
62
|
+
activate: (generation: number) => Promise<void>;
|
|
63
|
+
/** Drops the pending import if it is still `generation`'s; its rows go when the live store next loads. */
|
|
64
|
+
discard: (generation: number) => Promise<void>;
|
|
65
|
+
};
|