Files
stack/packages/conversation/src/parts.mjs
T
jason.woltjeandClaude Opus 5.5 a5beb6d97d feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session
parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control
board adds GET /api/conversations and /api/conversation behind the Host
and Origin guard. Both are read-only, their queries are validated, and
each refusal code maps to a status.

Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code:
R1 revise (branch ids moving on append, the assumed-link bridge merging
branches, one unreadable seat directory turning the catalogue into a 500),
then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve
(07b10ad1), R2 approve (b9d92003). The package lands with the routes,
because serve.mjs imports the reader at load.

On an index export: the eight suites 24/90/43/17/14/15/63/18,
conversation and control-board 153/153, webui 9/9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:36:20 -05:00

125 lines
4.3 KiB
JavaScript

// CHAT-01 history limits (#1507, CHAT-02): at most 100 parts per page, 8 MiB
// of serialized UTF-8 per page, 64 blocks per part and 262144 characters per
// string. Oversize content splits into fragments and continuation parts; it is
// never clipped. Byte limits are measured on the serialized JSON, not on
// characters.
import { createHash } from "node:crypto";
export const LIMITS = Object.freeze({ parts: 100, pageBytes: 8 * 1024 * 1024, blocks: 64, chars: 262144 });
// Internal budgets that keep any single part well inside one page, so a page
// always holds at least one part.
export const FRAGMENT_BYTES = 1024 * 1024;
export const PART_BYTES = 4 * 1024 * 1024;
export const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
// A native value used as a CHAT-01 id. Values that do not fit the id pattern
// (OpenAI tool calls such as "call_x|fc_y") map to a stable hash.
export function safeId(value) {
if (typeof value === "string" && ID.test(value)) return value;
return "h-" + createHash("sha256").update(String(value)).digest("hex").slice(0, 40);
}
// Bytes one code point adds to a JSON string literal.
function jsonCost(cp) {
if (cp === 0x22 || cp === 0x5c) return 2;
if (cp < 0x20) return cp === 8 || cp === 9 || cp === 10 || cp === 12 || cp === 13 ? 2 : 6;
if (cp < 0x80) return 1;
if (cp < 0x800) return 2;
if (cp >= 0xd800 && cp <= 0xdfff) return 6; // lone surrogate, escaped by JSON.stringify
if (cp <= 0xffff) return 3;
return 4;
}
// Splits a string into fragments of at most LIMITS.chars code points and
// FRAGMENT_BYTES of JSON. Never cuts a surrogate pair. "" is one fragment.
export function fragments(str) {
const out = [];
let start = 0, chars = 0, bytes = 0;
for (let i = 0; i < str.length;) {
const cp = str.codePointAt(i);
const width = cp > 0xffff ? 2 : 1;
const cost = jsonCost(cp);
if (chars > 0 && (chars + 1 > LIMITS.chars || bytes + cost > FRAGMENT_BYTES)) {
out.push(str.slice(start, i));
start = i;
chars = 0;
bytes = 0;
}
chars += 1;
bytes += cost;
i += width;
}
out.push(str.slice(start));
return out;
}
const bytesOf = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
// One native unit (a message, a compaction, a notice) becomes one or more
// CHAT-01 entries. `unit.blocks` holds logical blocks: { fields, key, value },
// where `value` is the string that may split and `key` names its field. A
// block with key null (an attachment) has no string and is one fragment.
export function unitParts(unit, base) {
const items = [];
unit.blocks.forEach((b, ordinal) => {
if (b.key === null) {
items.push({ ...b.fields, block: ordinal, fragment: 0, lastFragment: true });
return;
}
const pieces = fragments(b.value);
pieces.forEach((piece, k) => {
items.push({ ...b.fields, [b.key]: piece, block: ordinal, fragment: k, lastFragment: k === pieces.length - 1 });
});
});
const groups = [];
let current = [], currentBytes = 0;
for (const item of items) {
const size = bytesOf(item) + 1;
if (current.length && (current.length === LIMITS.blocks || currentBytes + size > PART_BYTES)) {
groups.push(current);
current = [];
currentBytes = 0;
}
current.push(item);
currentBytes += size;
}
groups.push(current);
return groups.map((content, part) => ({
version: 2,
kind: "entry",
id: safeId(`${unit.id}:${part}`),
conversation: base.conversation,
branch: base.branch,
parent: unit.parent,
execution: base.execution,
nativeEntry: unit.nativeEntry,
role: unit.role,
request: null,
content,
part,
lastPart: part === groups.length - 1,
createdAt: unit.createdAt,
message: unit.message,
}));
}
// Takes entries from `start` while the page stays within LIMITS. `shell` is
// the page record with an empty `entries` array.
export function takePage(entries, sizes, start, shell) {
let bytes = bytesOf(shell);
let end = start;
while (end < entries.length && end - start < LIMITS.parts) {
const add = sizes[end] + (end > start ? 1 : 0);
if (bytes + add > LIMITS.pageBytes) break;
bytes += add;
end += 1;
}
if (end === start && start < entries.length) throw new Error("internal: a single part exceeds the page byte limit");
return end;
}
export { bytesOf };