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,167 @@
|
|
|
1
|
+
import { isMime } from '../core/index.js';
|
|
2
|
+
/** A Durable Object's SQLite refuses a row over 2 MB. A message's text is stored as JSON in parts of at most this many UTF-16 units,
|
|
3
|
+
* so a part is at most ~1.5 MB of UTF-8 however the text is written, and a huge pasted email still fits. */
|
|
4
|
+
const PART = 512 * 1024;
|
|
5
|
+
/** Log, tree and view rows carry their generation `g`: an import builds a new one beside the live one. `optchat_state` names the
|
|
6
|
+
* live one, the pending import's (`target`, NULL when none) and the last one handed out; the planned log is in `optchat_plan`.
|
|
7
|
+
* Images are shared. */
|
|
8
|
+
const TABLES = `CREATE TABLE IF NOT EXISTS optchat_log (g INTEGER NOT NULL, i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (g, i, part));
|
|
9
|
+
CREATE TABLE IF NOT EXISTS optchat_tree (g INTEGER NOT NULL, l INTEGER NOT NULL, i INTEGER NOT NULL, size INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (g, l, i));
|
|
10
|
+
CREATE TABLE IF NOT EXISTS optchat_view (g INTEGER PRIMARY KEY, json TEXT NOT NULL);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS optchat_images (name TEXT PRIMARY KEY, mime TEXT NOT NULL, data BLOB NOT NULL);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS optchat_state (id INTEGER PRIMARY KEY CHECK (id = 0), live INTEGER NOT NULL, last INTEGER NOT NULL DEFAULT 0, target INTEGER, mode TEXT, total INTEGER);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS optchat_plan (i INTEGER NOT NULL, part INTEGER NOT NULL, meta TEXT, text TEXT NOT NULL, PRIMARY KEY (i, part));
|
|
14
|
+
INSERT OR IGNORE INTO optchat_state (id, live) VALUES (0, 0);`;
|
|
15
|
+
/** Splits `text` every PART units, never between the two halves of a surrogate pair. */
|
|
16
|
+
function parts(text) {
|
|
17
|
+
const result = [];
|
|
18
|
+
for (let at = 0; at < text.length;) {
|
|
19
|
+
let end = Math.min(text.length, at + PART);
|
|
20
|
+
if (end < text.length && /[\ud800-\udbff]/.test(text[end - 1]))
|
|
21
|
+
end--;
|
|
22
|
+
result.push(text.slice(at, end));
|
|
23
|
+
at = end;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/** An entry as rows of `(part, meta, text)`: its text as JSON in parts, its metadata on the first. */
|
|
28
|
+
const rows = ({ text, ...meta }) => parts(JSON.stringify(text)).map((chunk, part) => [part, part ? null : JSON.stringify(meta), chunk]);
|
|
29
|
+
/** Texts are stored as JSON, which keeps an unpaired surrogate that SQLite's TEXT would replace. A text that isn't there throws. */
|
|
30
|
+
function texts(rows, what) {
|
|
31
|
+
const found = [...rows].map(row => typeof row.text === 'string' ? row.text : '');
|
|
32
|
+
if (!found.length)
|
|
33
|
+
throw new Error(`No ${what} in this store.`);
|
|
34
|
+
const text = JSON.parse(found.join(''));
|
|
35
|
+
if (typeof text !== 'string')
|
|
36
|
+
throw new Error(`Invalid ${what} in this store.`);
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** OptChat's memory in any Chord `SqliteDatabase`, such as Node's (`NodeSqliteDatabase`) or a Durable Object's. Give it the
|
|
40
|
+
* database Pi Durable's storage uses, so both share one queue: in a Durable Object, the `DurableObjectSqliteDatabase` over
|
|
41
|
+
* `ctx.storage` (direct `ctx.storage.sql` writes could be rolled back with a Pi transaction). `read` reads texts from the same
|
|
42
|
+
* database synchronously; it only reads rows whose writes have finished, so it needs no queue. Its tables are `optchat_*`, beside
|
|
43
|
+
* Pi's `pi_*`. Memory loads only metadata and node sizes; texts stay here. Messages are split so no row passes a Durable Object's
|
|
44
|
+
* 2 MB limit; images go to `images` when given, otherwise to `optchat_images`, one row each. */
|
|
45
|
+
export class SqliteStore {
|
|
46
|
+
db;
|
|
47
|
+
read;
|
|
48
|
+
options;
|
|
49
|
+
#g = 0;
|
|
50
|
+
constructor(db, read, options = {}) {
|
|
51
|
+
this.db = db;
|
|
52
|
+
this.read = read;
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async load() {
|
|
56
|
+
await this.db.exec(TABLES);
|
|
57
|
+
if (this.options.generation !== undefined)
|
|
58
|
+
this.#g = this.options.generation;
|
|
59
|
+
else {
|
|
60
|
+
const state = await this.db.get('SELECT live, target FROM optchat_state');
|
|
61
|
+
this.#g = state?.live ?? 0;
|
|
62
|
+
// An old memory may still write to its generation until the object restarts after a switch, so old rows go only here.
|
|
63
|
+
await this.db.transaction(async (tx) => {
|
|
64
|
+
for (const table of ['log', 'tree', 'view'])
|
|
65
|
+
await tx.run(`DELETE FROM optchat_${table} WHERE g NOT IN (?, ?)`, this.#g, state?.target ?? this.#g);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const log = await this.db.all('SELECT meta FROM optchat_log WHERE g = ? AND part = 0 ORDER BY i', this.#g);
|
|
69
|
+
const tree = await this.db.all('SELECT l, i, size FROM optchat_tree WHERE g = ?', this.#g);
|
|
70
|
+
const view = await this.db.get('SELECT json FROM optchat_view WHERE g = ?', this.#g);
|
|
71
|
+
return { entries: log.map(row => JSON.parse(row.meta)), tree, view: view?.json };
|
|
72
|
+
}
|
|
73
|
+
text(i) { return texts(this.read('SELECT text FROM optchat_log WHERE g = ? AND i = ? ORDER BY part', this.#g, i), `message ${i}`); }
|
|
74
|
+
summary({ l, i }) { return texts(this.read('SELECT text FROM optchat_tree WHERE g = ? AND l = ? AND i = ?', this.#g, l, i), `summary ${l}/${i}`); }
|
|
75
|
+
/** The primary key refuses a second message at an index, so a store that was not the last to append writes nothing. */
|
|
76
|
+
async append(entry) {
|
|
77
|
+
try {
|
|
78
|
+
await this.db.transaction(async (tx) => {
|
|
79
|
+
for (const row of rows(entry))
|
|
80
|
+
await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) VALUES (?, ?, ?, ?, ?)', this.#g, entry.i, ...row);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (await this.db.get('SELECT 1 FROM optchat_log WHERE g = ? AND i = ? AND part = 0', this.#g, entry.i))
|
|
85
|
+
throw new Error(`Someone else already saved message ${entry.i}; nothing was written.`, { cause: error });
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async appendNode(node) {
|
|
90
|
+
await this.db.run('INSERT OR REPLACE INTO optchat_tree (g, l, i, size, text) VALUES (?, ?, ?, ?, ?)', this.#g, node.l, node.i, node.size, JSON.stringify(node.text));
|
|
91
|
+
}
|
|
92
|
+
async saveView(view) {
|
|
93
|
+
await this.db.run('INSERT OR REPLACE INTO optchat_view (g, json) VALUES (?, ?)', this.#g, view);
|
|
94
|
+
}
|
|
95
|
+
async putImage(name, mimeType, data) {
|
|
96
|
+
if (this.options.images)
|
|
97
|
+
return this.options.images.putImage(name, mimeType, data);
|
|
98
|
+
await this.db.run('INSERT OR REPLACE INTO optchat_images (name, mime, data) VALUES (?, ?, ?)', name, mimeType, data);
|
|
99
|
+
}
|
|
100
|
+
async image(name) {
|
|
101
|
+
if (this.options.images)
|
|
102
|
+
return this.options.images.image(name);
|
|
103
|
+
const row = await this.db.get('SELECT mime, data FROM optchat_images WHERE name = ?', name);
|
|
104
|
+
return row && isMime(row.mime) ? { mimeType: row.mime, data: row.data } : undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The rows `optchatImport` keeps: which generation is live, the pending import and its plan. */
|
|
108
|
+
export function importTables(db, read) {
|
|
109
|
+
const state = async (tx = db) => {
|
|
110
|
+
await tx.exec(TABLES);
|
|
111
|
+
const row = await tx.get('SELECT live, target, mode, total FROM optchat_state');
|
|
112
|
+
const job = row?.target != null && row.mode && row.total != null ? { generation: row.target, mode: row.mode, total: row.total } : undefined;
|
|
113
|
+
return { live: row?.live ?? 0, job };
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
state: () => state(),
|
|
117
|
+
/** Saves the plan, `entries` in order, made from generation `from`, and makes it the pending import of a new generation. A
|
|
118
|
+
* 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
|
|
119
|
+
* over them, and its view. Refused unless `from` is still live: an import went live since. */
|
|
120
|
+
stage: (from, mode, entries, kept) => db.transaction(async (tx) => {
|
|
121
|
+
const { live, job } = await state(tx);
|
|
122
|
+
if (job)
|
|
123
|
+
throw new Error('An import is pending: let it finish, or discard it.');
|
|
124
|
+
if (live !== from)
|
|
125
|
+
throw new Error('An import went live: restart before the next one.');
|
|
126
|
+
const generation = ((await tx.get('SELECT last FROM optchat_state'))?.last ?? live) + 1;
|
|
127
|
+
await tx.run('DELETE FROM optchat_plan');
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
total++;
|
|
131
|
+
for (const row of rows(entry))
|
|
132
|
+
await tx.run('INSERT INTO optchat_plan (i, part, meta, text) VALUES (?, ?, ?, ?)', entry.i, ...row);
|
|
133
|
+
}
|
|
134
|
+
if (kept) {
|
|
135
|
+
// Nodes only over the messages copied: a message still being written isn't, and a node past the log would refuse to open.
|
|
136
|
+
// The view keeps its cached prefix; one that covers more than was copied is rebuilt.
|
|
137
|
+
await tx.run('INSERT INTO optchat_log (g, i, part, meta, text) SELECT ?, i, part, meta, text FROM optchat_log WHERE g = ? AND i < ?', generation, live, kept);
|
|
138
|
+
await tx.run(`INSERT INTO optchat_tree (g, l, i, size, text) SELECT ?, l, i, size, text FROM optchat_tree
|
|
139
|
+
WHERE g = ? AND ((i + 1) << l) <= (SELECT COUNT(*) FROM optchat_log WHERE g = ? AND part = 0)`, generation, live, generation);
|
|
140
|
+
await tx.run('INSERT INTO optchat_view (g, json) SELECT ?, json FROM optchat_view WHERE g = ?', generation, live);
|
|
141
|
+
}
|
|
142
|
+
await tx.run('UPDATE optchat_state SET last = ?, target = ?, mode = ?, total = ?', generation, generation, mode, total);
|
|
143
|
+
return { generation, mode, total };
|
|
144
|
+
}),
|
|
145
|
+
/** Planned message `i`, as saved; validated where it is used. */
|
|
146
|
+
planned(i) {
|
|
147
|
+
const [first] = read('SELECT meta FROM optchat_plan WHERE i = ? AND part = 0', i);
|
|
148
|
+
if (typeof first?.meta !== 'string')
|
|
149
|
+
throw new Error(`No planned message ${i} in this store.`);
|
|
150
|
+
return { ...JSON.parse(first.meta), text: texts(read('SELECT text FROM optchat_plan WHERE i = ? ORDER BY part', i), `planned message ${i}`) };
|
|
151
|
+
},
|
|
152
|
+
/** Makes `generation` live and drops its plan, in one transaction; refused if its import was discarded meanwhile. */
|
|
153
|
+
activate: (generation) => db.transaction(async (tx) => {
|
|
154
|
+
if ((await state(tx)).job?.generation !== generation)
|
|
155
|
+
throw new Error('The import was discarded.');
|
|
156
|
+
await tx.run('UPDATE optchat_state SET live = target, target = NULL, mode = NULL, total = NULL');
|
|
157
|
+
await tx.run('DELETE FROM optchat_plan');
|
|
158
|
+
}),
|
|
159
|
+
/** Drops the pending import if it is still `generation`'s; its rows go when the live store next loads. */
|
|
160
|
+
discard: (generation) => db.transaction(async (tx) => {
|
|
161
|
+
if ((await state(tx)).job?.generation !== generation)
|
|
162
|
+
return;
|
|
163
|
+
await tx.run('UPDATE optchat_state SET target = NULL, mode = NULL, total = NULL');
|
|
164
|
+
await tx.run('DELETE FROM optchat_plan');
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
}
|
package/docs/core.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Core imports nothing from Pi or Node at runtime: only `typebox` (the tools' schemas) and pi-ai's types. `test/core-boundary.test.ts` keeps it that way. Pi's packages are optional peer dependencies, so installing `pi-optchat` outside Pi pulls in none of them.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`pi-optchat/core` and `pi-optchat/durable` ship as compiled JavaScript with declarations, so a host needs no special tsconfig or bundler.
|
|
8
8
|
|
|
9
9
|
## What a host supplies
|
|
10
10
|
|
package/docs/durable.md
CHANGED
|
@@ -16,7 +16,7 @@ memoryExtension.attach(harness, context);
|
|
|
16
16
|
|
|
17
17
|
With the Agents SDK's `PiHarness` this goes in its `harness` factory, which supplies `storage`, `context` and `onWake` and resumes the Harness when an alarm wakes the object. A host without it passes an `onWake` that arms an alarm, and calls `harness.resume()` on open and on each alarm, or a task owed from before an eviction never runs.
|
|
18
18
|
|
|
19
|
-
`examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file
|
|
19
|
+
`examples/durable-object` is a Worker built this way, importing `pi-optchat/core` and `pi-optchat/durable` as an app would (linked with `file:../..`, so run `npm run build` in the repo first: the package's `./core` and `./durable` are the built `dist/`), with the counters and endpoints its `proof.mjs` uses: it runs the Worker under `wrangler dev` with real model calls and checks a multi-turn chat, restarts and the cache.
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-optchat",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
7
|
-
"./core":
|
|
8
|
-
|
|
7
|
+
"./core": {
|
|
8
|
+
"types": "./dist/core/index.d.ts",
|
|
9
|
+
"import": "./dist/core/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./durable": {
|
|
12
|
+
"types": "./dist/durable/index.d.ts",
|
|
13
|
+
"import": "./dist/durable/index.js"
|
|
14
|
+
},
|
|
9
15
|
"./package.json": "./package.json"
|
|
10
16
|
},
|
|
11
17
|
"pi": {
|
|
@@ -16,10 +22,13 @@
|
|
|
16
22
|
},
|
|
17
23
|
"scripts": {
|
|
18
24
|
"check": "tsc --noEmit",
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"prepack": "npm run build",
|
|
19
27
|
"test": "tsx --import ./test/support.ts --test test/*.test.ts",
|
|
20
28
|
"test:live": "tsx test/live.ts"
|
|
21
29
|
},
|
|
22
30
|
"devDependencies": {
|
|
31
|
+
"@cloudflare/workers-types": "^5.20261031.1",
|
|
23
32
|
"@earendil-works/chord": "1.4.0",
|
|
24
33
|
"@earendil-works/pi-agent-core": "1.4.0",
|
|
25
34
|
"@earendil-works/pi-ai": "1.4.0",
|
|
@@ -80,6 +89,7 @@
|
|
|
80
89
|
},
|
|
81
90
|
"files": [
|
|
82
91
|
"src",
|
|
92
|
+
"dist",
|
|
83
93
|
"docs/*.md",
|
|
84
94
|
"README.md",
|
|
85
95
|
"LICENSE",
|