@hachej/boring-agent 0.4.5 → 0.4.7
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 +7 -6
- package/dist/documents.js +7 -1
- package/dist/memory/optchat-core.d.ts +13 -6
- package/dist/memory/optchat-core.js +42 -27
- package/dist/memory/optchat.d.ts +11 -4
- package/dist/memory/optchat.js +149 -39
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ The host supplies byte access and optional OCR; this package performs no file/ne
|
|
|
91
91
|
|
|
92
92
|
Results use native `output` for the model and the same metadata in `details`. As in the reference, typed refusals are ordinary results carrying `error`, not `isError: true`. Document content is untrusted evidence, never instructions or consent. The readers set Pi's native output byte limit to 8 MiB to retain valid JSON at the extraction caps; the JSON occupies one native output line. These are returned-extraction limits, not bounds on input bytes or parser memory/CPU. Enforce input and resource budgets in the host.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
PDF reading uses the pinned regular dependency `unpdf@1.9.1` (MIT). Spreadsheet reading needs the optional peer `xlsx` (SheetJS, Apache-2.0); install the official distribution in the host with `npm i https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz`, not npm's old `xlsx@0.18.5`. Without it, the spreadsheet tool returns a clear refusal; PDF reading still works. Both packages are imported lazily inside their reader only. Their notices ship in `THIRD_PARTY_NOTICES.md`. `test/packages/agent-documents.test.mjs` drives public registrations through a real Harness with generated fictional files, and `npm run test:question-consumer` exercises the packed agent without SheetJS; no live OCR provider is qualified.
|
|
95
95
|
|
|
96
96
|
## Approval gate
|
|
97
97
|
|
|
@@ -114,13 +114,13 @@ await conversation.configure({ extensions: { add: [memory.extension] } }, contex
|
|
|
114
114
|
await conversation.configure({ extensions: { remove: [memory.extension] } }, context); // off again
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
Pi applies an extension's hooks only in conversations that select it, so memory is opt-in per conversation and there is no separate mode. A host that selects extensions by default selects it everywhere; give conversations an explicit `extensions` list to keep it opt-in. `createOptChatMemory(options)` returns `{ extension, nap(id), stats(id), zoom(id, leafId, n), date(id, leafId), forgetMessage(id, messageIndex), forget(id), dispose() }`. Options: `harness`, `context` (long-lived, for background work), `summarizer` (required: `{ model }`, where `model` may be a function `(conversationId) => ModelRef`, sync or async, for example that conversation's own model, and `models` defaults to the Harness's; or `{ summarize(job, signal) }` returning one line), `nodeBytes` (512), `viewBytes` (128000), `compactBytes` (32000, the compactor
|
|
117
|
+
Pi applies an extension's hooks only in conversations that select it, so memory is opt-in per conversation and there is no separate mode. A host that selects extensions by default selects it everywhere; give conversations an explicit `extensions` list to keep it opt-in. `createOptChatMemory(options)` returns `{ extension, nap(id), stats(id), zoom(id, leafId, n), date(id, leafId), forgetMessage(id, messageIndex), forget(id), dispose() }`. Options: `harness`, `context` (long-lived, for background work), `summarizer` (required: `{ model }`, where `model` may be a function `(conversationId) => ModelRef`, sync or async, for example that conversation's own model, and `models` defaults to the Harness's; or `{ summarize(job, signal) }` returning one line), `nodeBytes` (512), `viewBytes` (128000), `compactBytes` (32000, the cold-start/callback compactor view), `foldHighWater` (1.25) and `foldLowWater` (0.75), fractions of both view budgets, `notes` (seed strings, stored once per conversation before the transcript), `workOf(message)` (host classification of subagent or job reports), `jobs` (64, how many summary tasks of a conversation the host keeps live at once), `tries` (5), `cap` (30000), `retryMs` (10000 initially, doubling after each failure to at most 60000), `settleTimeoutMs` (60000), `agentName`, `name`, `onError`. `dispose()` only stops this process's waits: the summary tasks belong to Pi, which keeps and recovers them, so it is harmless to skip and safe to call before `harness.close`.
|
|
118
118
|
|
|
119
119
|
| Reference | Here |
|
|
120
120
|
| --- | --- |
|
|
121
121
|
| Append-only log of user, talk, tool and echo messages | The Pi transcript, never rewritten. Leaves are derived from the model context: a user message is `user`, assistant text `talk`, each tool call `tool`, each tool result `echo`; thinking is not logged. A leaf over `cap` characters keeps head and tail. Seeded `note` leaves precede them; messages marked by `workOf` become one `work` leaf |
|
|
122
122
|
| Summary tree: node (l, i) covers messages [i·2^l, (i+1)·2^l), at most `nodeBytes` | The same, purely binary. A short message, or two children that fit together, are their own text and cost no call |
|
|
123
|
-
| The view tiles the whole chat, one `id+n\|text` line per node, fit incrementally by merging the most due pair (age / 2^(level+2)), never splitting | The
|
|
123
|
+
| The view tiles the whole chat, one `id+n\|text` line per node, fit incrementally by merging the most due pair (age / 2^(level+2)), never splitting | The checkpoint fold (`advanceFold`): append between checkpoints; above 125% of the budget, merge deterministically down to 75%. Both fractions are configurable. The public four-argument core helper retains strict fitting. `renderView` produces the `<chat>` block |
|
|
124
124
|
| Each turn calls the model with [system][view][new message] | `GenerationTask.beforeRequest` replaces the messages before the current turn with the view, as four-line text blocks at the start of the current turn's first message. The turn in progress starts at the latest user or work message. Work reports and sibling results whose tool calls fell outside the turn become ordinary user-role input, with their full text preserved. Every positional system message is kept in order. The explanation of the view is a prompt section, so the system prompt and tools never change between calls |
|
|
125
125
|
| Compactor: context first, no ids, scale line, resubmit with the cut at the limit, `JOBS` in parallel, patient retry | The same protocol (`askModel`), run by native pi-durable tasks: each node `(level, index, fingerprint)` is one `boring.memory.optchat.node` task (shipped in the extension's `tasks`, input `{ key, level, index, of, upTo }`, one `summarize` phase) that calls the summarizer through `runtime.models` (or your `summarize`) and, in one commit, writes the node and the summarizer's usage into the extension's state. Pi schedules, runs and recovers them. See below |
|
|
126
126
|
| A turn waits until every line of the view is a summary and never shows cut text | The request waits up to `settleTimeoutMs`; after that the missing lines show `(not summarized yet: zoom it)`, never a clipped message, and `zoom(id, 1)` returns the message whole |
|
|
@@ -134,14 +134,15 @@ What is native, what is host-side:
|
|
|
134
134
|
- **Waiting.** A request waits with `waitForTask` on the live node tasks, up to `settleTimeoutMs`, then shows the placeholder. `nap` waits until all nodes are built, including retries. These waits are in-process; if the process dies the tasks are not lost, only the cascade to parents waits for the next trigger.
|
|
135
135
|
- **Retry.** A failed node is a failed native task (`outcome.failed`). Its failure time and streak length live in `boring.memory.optchat.state` with call count and spend. The host's idle admission loop waits from `retryMs`, doubling to at most 60 seconds, then creates another native task without a new turn. `dispose()` aborts those waits, not Pi's tasks. After a process restart, `nap` or another trigger restarts admission. Requests retain their bounded wait and can show placeholders during an outage. `onError` reports only the first failure of a streak.
|
|
136
136
|
- **Usage.** In the commit that stores a node (or records its failure) the task adds the summarizer's usage, all attempts of the cut-and-resubmit loop included, to the extension's own state document (`boring.memory.optchat.state`), reported by `stats().compactor.usage`. It is not written into Pi's `pi.usage`: Pi has no public way for a task to record model usage (`recordUsage` is internal), so per BORING-PI-4 that guarantee is unavailable here rather than worked around; a public usage-recording seam is an upstream request. Hosts that budget model spend add `stats().compactor.usage` to `harness.usage()`. Task and document kinds are all in `boring.memory.optchat.*`; this package writes no `pi.*` document.
|
|
137
|
-
- **Still host-side.** The trigger and admission logic, the
|
|
137
|
+
- **Still host-side.** The trigger and admission logic, the log cache and the summarizer prompt. Context refreshes reuse a prior leaf only after exact kind, capped-text and timestamp comparison, avoiding repeated byte counting and hashing without trusting native message identity. The emitted turn and compactor tilings, open flags and content fingerprints are retained in `boring.memory.optchat.fold` (no duplicated message or summary text), so cache eviction and restart restore the same prefix with unchanged options and history. A sent interior placeholder stays frozen until a checkpoint (the tail may settle); later summaries remain accessible through zoom. History changes, forgetting or changed fold options rebuild the view. A custom `summarize` function has no model usage to record. A model call interrupted by a crash is rerun from the start of the task, and its spend is not recorded.
|
|
138
138
|
|
|
139
139
|
Deviations, honestly:
|
|
140
140
|
|
|
141
141
|
- The log is Pi's model context, not an OptChat file, so ids are positions in that context. A native compaction, reset or edit that changes earlier messages invalidates the summaries it touches (fingerprints), and the view is refolded. `beforeCompact` declines while the extension is selected: the view replaces compaction, and a native summary would cut the transcript the view is built from. That is the only effect on Pi's lifecycle.
|
|
142
142
|
- The current turn is verbatim (including its tool steps) instead of being summarized to one line each as soon as it is logged; it enters the view when the next user message arrives. A user message sent mid-run starts a new "current turn" for the next step.
|
|
143
143
|
- A request is rewritten once per attempt from the full context. The summaries are a cache rebuilt from the log; the summarizer's calls are not Pi generations: they run as Pi tasks and their usage is in `stats().compactor.usage`, not `pi.usage` (see above).
|
|
144
|
-
- Both views use four-line text blocks
|
|
144
|
+
- Both views use four-line text blocks. Model compactor requests carry the entire last emitted turn view (bare lines), even when their job revisits older history; the admitted `upTo` still bounds the source they may summarize. Before the first turn, native documents retain one frozen, whole-block prefix within `compactBytes × foldHighWater`. Callback summarizers keep their past-only `compactBytes` context. The constant size example is in the summarizer system prompt rather than every job suffix. The first native task on a new conversation/model/prefix sends alone; other admitted tasks await its response, then send in parallel within the same `jobs` cap. Failure or cancellation releases the warmup wait. This is process-local request coordination, not a scheduler or proof a provider retained its cache.
|
|
145
|
+
- `stats().compactor.requests` exposes provider-reported input, cache reads/writes, output and cost for the most recent 256 responses booked by settled native tasks, including retries, oldest first. The history limit is `COMPACTOR_REQUEST_HISTORY_LIMIT`, next to `DEFAULTS`; lifetime `calls` and aggregate `usage` totals are not truncated. These records survive restart; old state documents start with no request records. Interrupted tasks can lose uncommitted usage, as before. Pi 1.2 exposes no per-block cache marker: the fictional Anthropic replay supplies host breakpoints and measures cold starts plus 100 turns at 600/full-fold and 400/default messages. It enforces ≥95% cache reads, fewer uncached tokens and no extra summarizer calls; this is not live-provider qualification. Longer cached context increases total input. Qualify the host's chosen budgets, breakpoints and retention with its provider. There is no separate `fsync` log or single-writer lock; Pi's storage owns durability. Summaries are read from documents on every request and stats call.
|
|
145
146
|
- Cost: the compactor makes about two calls per message, each carrying its small view as context. Choose a cheap summarizer model; the browser-agent example uses the chat model only because it has one.
|
|
146
147
|
- The package runs in Node and in a browser (no Node-only imports). Fictional-data tests are in `test/packages/agent.test.mjs`; `examples/bot` and `examples/browser-agent` have journeys.
|
|
147
148
|
|
|
@@ -149,7 +150,7 @@ Deviations, honestly:
|
|
|
149
150
|
|
|
150
151
|
`await memory.forgetMessage(conversationId, messageIndex)` forgets one message in Pi's current model context. `messageIndex` is its zero-based position in `(await conversation.context(context)).messages`, including system-message positions. System messages cannot be forgotten. This is distinct from the leaf id used by `zoom`; seeded notes and assistant tool calls can shift leaf ids.
|
|
151
152
|
|
|
152
|
-
The operation replaces every leaf of that message with `(forgotten)` at the same positions. One native commit stores the tombstone and deletes those leaf summaries, their ancestors, later summaries and corresponding failure records.
|
|
153
|
+
The operation replaces every leaf of that message with `(forgotten)` at the same positions. One native commit stores the tombstone and deletes those leaf summaries, their ancestors, later summaries and corresponding failure records. Once a model summarizer has used the shared prefix, forgetting conservatively deletes all summaries: earlier nodes and transitive parents can also copy later facts. That fact is retained across restart and summarizer configuration changes. With only callback summaries, later summaries can contain facts resolved from earlier context, so deleting only the logarithmic structural ancestors would leave copies behind. Rebuilding can therefore cost more than logarithmic work. Later leaves keep their ids and fingerprints. Native tasks rebuild the invalidated summaries. In-flight calls recheck the memory revision inside their commit and cannot restore old output. A chat request waiting on summaries also rechecks that revision before returning its rewritten messages. `nap` waits for rebuilding. Repeated forgetting is harmless. `forget(id)` only evicts the process cache and does not undo tombstones.
|
|
153
154
|
|
|
154
155
|
This changes active memory only, not requests already dispatched. Pi's session entries, native task history, underlying storage pages, provider logs, backups and copies quoted in other messages or existing forks are not erased. Indices address the current context, so a reset or history replacement requires the host to resolve positions again. Hosts must authorize the operation. Seed notes are stored once in `boring.memory.optchat.notes`; changing `options.notes` does not overwrite an existing conversation's notes.
|
|
155
156
|
|
package/dist/documents.js
CHANGED
|
@@ -147,7 +147,13 @@ async function readSpreadsheet(path, bytes, sheet, range) {
|
|
|
147
147
|
const type = sniff(bytes, path);
|
|
148
148
|
if (!SPREADSHEET_TYPES.includes(type))
|
|
149
149
|
return refused(path, type, bytes.byteLength, 'Not a spreadsheet');
|
|
150
|
-
|
|
150
|
+
let XLSX;
|
|
151
|
+
try {
|
|
152
|
+
XLSX = await import('xlsx');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return refused(path, type, bytes.byteLength, 'Spreadsheet reading needs the optional package xlsx (SheetJS); install it in the host');
|
|
156
|
+
}
|
|
151
157
|
let book;
|
|
152
158
|
try {
|
|
153
159
|
book = type === TYPES.csv ? XLSX.read(textOf(bytes), { type: 'string', raw: true }) : XLSX.read(bytes, { type: 'array', cellDates: true });
|
|
@@ -6,6 +6,9 @@ export declare const DEFAULTS: {
|
|
|
6
6
|
readonly viewBytes: 128000;
|
|
7
7
|
/** Independent view budget for summary requests. */
|
|
8
8
|
readonly compactBytes: 32000;
|
|
9
|
+
/** Checkpoint thresholds, as fractions of each view budget. */
|
|
10
|
+
readonly foldHighWater: 1.25;
|
|
11
|
+
readonly foldLowWater: 0.75;
|
|
9
12
|
/** Summarizer calls running at once. */
|
|
10
13
|
readonly jobs: 64;
|
|
11
14
|
/** Attempts per node to get a line under `nodeBytes`. */
|
|
@@ -17,6 +20,7 @@ export declare const DEFAULTS: {
|
|
|
17
20
|
/** How long a request waits for the summaries it needs. */
|
|
18
21
|
readonly settleTimeoutMs: 60000;
|
|
19
22
|
};
|
|
23
|
+
export declare const COMPACTOR_REQUEST_HISTORY_LIMIT = 256;
|
|
20
24
|
/** What a view line shows for a message that has no summary yet. Only a fail-safe: requests wait for summaries. */
|
|
21
25
|
export declare const PLACEHOLDER = "(not summarized yet: zoom it)";
|
|
22
26
|
/** UTF-8 length without allocating. */
|
|
@@ -49,7 +53,8 @@ export interface LeafOptions {
|
|
|
49
53
|
/** Non-system message ordinals mapped to original leaf counts. Positional system updates must not shift tombstones. */
|
|
50
54
|
readonly forgotten?: Readonly<Record<string, number>>;
|
|
51
55
|
}
|
|
52
|
-
|
|
56
|
+
/** Reuse prior leaves only after exact kind, capped-text and timestamp comparison; native contexts may contain fresh message objects. */
|
|
57
|
+
export declare function leavesOf(messages: readonly Message[], cap?: number, options?: LeafOptions, previous?: readonly Leaf[]): {
|
|
53
58
|
leaves: Leaf[];
|
|
54
59
|
before: number[];
|
|
55
60
|
};
|
|
@@ -96,14 +101,16 @@ export interface ViewFold {
|
|
|
96
101
|
T: number;
|
|
97
102
|
size: number;
|
|
98
103
|
parts: Part[];
|
|
104
|
+
/** Leaves already sent: interior placeholders stay frozen until a checkpoint. */
|
|
105
|
+
stableThrough?: number;
|
|
106
|
+
/** A checkpoint waiting for its required summaries; its due priorities keep the original leaf count. */
|
|
107
|
+
checkpoint?: boolean;
|
|
108
|
+
checkpointAt?: number;
|
|
99
109
|
}
|
|
100
110
|
export declare const emptyFold: () => ViewFold;
|
|
101
111
|
export declare const partStart: (part: Pick<Part, "level" | "index">) => number;
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
* only ever appends at its end and coarsens, so its start stays the same from one request to the next (§5.2).
|
|
105
|
-
*/
|
|
106
|
-
export declare function advanceFold(fold: ViewFold, tree: OptChatTree, to: number, viewBytes: number): ViewFold;
|
|
112
|
+
/** Append one leaf at a time; coarsen from the high mark to the low mark in a single checkpoint. */
|
|
113
|
+
export declare function advanceFold(fold: ViewFold, tree: OptChatTree, to: number, viewBytes: number, highWater?: number, lowWater?: number): ViewFold;
|
|
107
114
|
/** The view's parts that still wait for a summary. */
|
|
108
115
|
export declare const openParts: (fold: ViewFold) => Part[];
|
|
109
116
|
/** One line per part, `id+n|text`, newlines as spaces, wrapped in `<chat>` (§5.1). */
|
|
@@ -5,6 +5,9 @@ export const DEFAULTS = {
|
|
|
5
5
|
viewBytes: 128_000,
|
|
6
6
|
/** Independent view budget for summary requests. */
|
|
7
7
|
compactBytes: 32_000,
|
|
8
|
+
/** Checkpoint thresholds, as fractions of each view budget. */
|
|
9
|
+
foldHighWater: 1.25,
|
|
10
|
+
foldLowWater: 0.75,
|
|
8
11
|
/** Summarizer calls running at once. */
|
|
9
12
|
jobs: 64,
|
|
10
13
|
/** Attempts per node to get a line under `nodeBytes`. */
|
|
@@ -16,6 +19,7 @@ export const DEFAULTS = {
|
|
|
16
19
|
/** How long a request waits for the summaries it needs. */
|
|
17
20
|
settleTimeoutMs: 60_000,
|
|
18
21
|
};
|
|
22
|
+
export const COMPACTOR_REQUEST_HISTORY_LIMIT = 256;
|
|
19
23
|
/** What a view line shows for a message that has no summary yet. Only a fail-safe: requests wait for summaries. */
|
|
20
24
|
export const PLACEHOLDER = '(not summarized yet: zoom it)';
|
|
21
25
|
/** UTF-8 length without allocating. */
|
|
@@ -72,13 +76,17 @@ export function capText(text, cap) {
|
|
|
72
76
|
const head = Math.ceil(cap / 2), tail = Math.floor(cap / 2);
|
|
73
77
|
return `${text.slice(0, head)}\n[… ${text.length - cap} characters cut …]\n${text.slice(text.length - tail)}`;
|
|
74
78
|
}
|
|
75
|
-
function leafOf(kind, text, at, cap) {
|
|
79
|
+
function leafOf(kind, text, at, cap, previous) {
|
|
76
80
|
const capped = capText(text, cap);
|
|
81
|
+
if (previous?.kind === kind && previous.text === capped && previous.at === at)
|
|
82
|
+
return previous;
|
|
77
83
|
const line = `${kind}: ${capped}`;
|
|
78
84
|
return { kind, text: capped, line, bytes: byteLength(line), hash: hashText(line), ...(at === undefined ? {} : { at }) };
|
|
79
85
|
}
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
/** Reuse prior leaves only after exact kind, capped-text and timestamp comparison; native contexts may contain fresh message objects. */
|
|
87
|
+
export function leavesOf(messages, cap = DEFAULTS.cap, options = {}, previous = []) {
|
|
88
|
+
const leaves = (options.notes ?? []).map((text, index) => leafOf('note', text, undefined, cap, previous[index])), before = [];
|
|
89
|
+
const append = (kind, text, at) => leaves.push(leafOf(kind, text, at, cap, previous[leaves.length]));
|
|
82
90
|
let messageIndex = 0;
|
|
83
91
|
for (const message of messages) {
|
|
84
92
|
before.push(leaves.length);
|
|
@@ -87,24 +95,24 @@ export function leavesOf(messages, cap = DEFAULTS.cap, options = {}) {
|
|
|
87
95
|
const forgotten = options.forgotten?.[String(messageIndex++)];
|
|
88
96
|
if (forgotten !== undefined) {
|
|
89
97
|
for (let i = 0; i < forgotten; i++)
|
|
90
|
-
|
|
98
|
+
append('note', '(forgotten)', undefined);
|
|
91
99
|
continue;
|
|
92
100
|
}
|
|
93
101
|
const at = typeof message.timestamp === 'number' ? message.timestamp : undefined;
|
|
94
102
|
if (options.workOf?.(message))
|
|
95
|
-
|
|
103
|
+
append('work', textOf(message.content), at);
|
|
96
104
|
else if (message.role === 'user')
|
|
97
|
-
|
|
105
|
+
append('user', textOf(message.content), at);
|
|
98
106
|
else if (message.role === 'assistant') {
|
|
99
107
|
const talk = message.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
100
108
|
if (talk.trim())
|
|
101
|
-
|
|
109
|
+
append('talk', talk, at);
|
|
102
110
|
for (const part of message.content)
|
|
103
111
|
if (part.type === 'toolCall')
|
|
104
|
-
|
|
112
|
+
append('tool', `${part.name} ${JSON.stringify(part.arguments)}`, at);
|
|
105
113
|
}
|
|
106
114
|
else if (message.role === 'toolResult')
|
|
107
|
-
|
|
115
|
+
append('echo', `${message.toolName}${message.isError ? ' (error)' : ''}: ${textOf(message.content)}`, at);
|
|
108
116
|
}
|
|
109
117
|
before.push(leaves.length);
|
|
110
118
|
return { leaves, before };
|
|
@@ -193,7 +201,7 @@ const label = (part) => `${partStart(part)}+${span(part.level)}`;
|
|
|
193
201
|
const lineBytes = (part, text) => byteLength(label(part)) + 1 + byteLength(flat(text)) + 1;
|
|
194
202
|
function settleOpen(fold, tree) {
|
|
195
203
|
for (const part of fold.parts) {
|
|
196
|
-
if (!part.open)
|
|
204
|
+
if (!part.open || (!fold.checkpoint && partStart(part) < (fold.stableThrough ?? 0) && part !== fold.parts.at(-1)))
|
|
197
205
|
continue;
|
|
198
206
|
const text = tree.text(part.level, part.index);
|
|
199
207
|
if (text === undefined)
|
|
@@ -204,37 +212,45 @@ function settleOpen(fold, tree) {
|
|
|
204
212
|
part.open = false;
|
|
205
213
|
}
|
|
206
214
|
}
|
|
207
|
-
/** Merge
|
|
208
|
-
function fit(fold, tree,
|
|
209
|
-
|
|
215
|
+
/** Merge by due priority fixed at the checkpoint; never skip an unbuilt preferred parent. */
|
|
216
|
+
function fit(fold, tree, high, low) {
|
|
217
|
+
if (fold.size > high && !fold.checkpoint) {
|
|
218
|
+
fold.checkpoint = true;
|
|
219
|
+
fold.checkpointAt = fold.T;
|
|
220
|
+
}
|
|
221
|
+
if (!fold.checkpoint)
|
|
222
|
+
return;
|
|
223
|
+
settleOpen(fold, tree);
|
|
224
|
+
while (fold.size > low) {
|
|
210
225
|
let best = -1, bestDue = -Infinity;
|
|
211
226
|
for (let p = 0; p + 1 < fold.parts.length; p++) {
|
|
212
227
|
const a = fold.parts[p], b = fold.parts[p + 1];
|
|
213
228
|
if (a.level !== b.level || a.index % 2 !== 0 || b.index !== a.index + 1)
|
|
214
229
|
continue;
|
|
215
|
-
|
|
216
|
-
continue;
|
|
217
|
-
const due = (fold.T - partStart(a)) / 2 ** (a.level + 2);
|
|
230
|
+
const due = ((fold.checkpointAt ?? fold.T) - partStart(a)) / 2 ** (a.level + 2);
|
|
218
231
|
if (due > bestDue) {
|
|
219
232
|
best = p;
|
|
220
233
|
bestDue = due;
|
|
221
234
|
}
|
|
222
235
|
}
|
|
223
236
|
if (best < 0)
|
|
224
|
-
|
|
237
|
+
break;
|
|
225
238
|
const a = fold.parts[best], b = fold.parts[best + 1];
|
|
239
|
+
const text = tree.text(a.level + 1, a.index / 2);
|
|
240
|
+
if (text === undefined)
|
|
241
|
+
return;
|
|
226
242
|
const merged = { level: a.level + 1, index: a.index / 2, bytes: 0, open: false };
|
|
227
|
-
merged.bytes = lineBytes(merged,
|
|
243
|
+
merged.bytes = lineBytes(merged, text);
|
|
228
244
|
fold.size += merged.bytes - a.bytes - b.bytes;
|
|
229
245
|
fold.parts.splice(best, 2, merged);
|
|
230
246
|
}
|
|
247
|
+
fold.checkpoint = false;
|
|
248
|
+
delete fold.checkpointAt;
|
|
231
249
|
}
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
* only ever appends at its end and coarsens, so its start stays the same from one request to the next (§5.2).
|
|
235
|
-
*/
|
|
236
|
-
export function advanceFold(fold, tree, to, viewBytes) {
|
|
250
|
+
/** Append one leaf at a time; coarsen from the high mark to the low mark in a single checkpoint. */
|
|
251
|
+
export function advanceFold(fold, tree, to, viewBytes, highWater = 1, lowWater = 1) {
|
|
237
252
|
settleOpen(fold, tree);
|
|
253
|
+
fit(fold, tree, viewBytes * highWater, viewBytes * lowWater);
|
|
238
254
|
while (fold.T < to) {
|
|
239
255
|
const index = fold.T++;
|
|
240
256
|
const text = tree.text(0, index);
|
|
@@ -242,22 +258,21 @@ export function advanceFold(fold, tree, to, viewBytes) {
|
|
|
242
258
|
part.bytes = lineBytes(part, text ?? PLACEHOLDER);
|
|
243
259
|
fold.parts.push(part);
|
|
244
260
|
fold.size += part.bytes;
|
|
245
|
-
fit(fold, tree, viewBytes);
|
|
261
|
+
fit(fold, tree, viewBytes * highWater, viewBytes * lowWater);
|
|
246
262
|
}
|
|
247
|
-
fit(fold, tree, viewBytes);
|
|
248
263
|
return fold;
|
|
249
264
|
}
|
|
250
265
|
/** The view's parts that still wait for a summary. */
|
|
251
266
|
export const openParts = (fold) => fold.parts.filter(part => part.open);
|
|
252
267
|
/** One line per part, `id+n|text`, newlines as spaces, wrapped in `<chat>` (§5.1). */
|
|
253
268
|
export function renderView(fold, tree) {
|
|
254
|
-
const lines = fold.parts.map(part => `${label(part)}|${flat(tree.text(part.level, part.index) ?? PLACEHOLDER)}`);
|
|
269
|
+
const lines = fold.parts.map(part => `${label(part)}|${flat(part.open ? PLACEHOLDER : tree.text(part.level, part.index) ?? PLACEHOLDER)}`);
|
|
255
270
|
return `<chat>\n${lines.join('\n')}\n</chat>`;
|
|
256
271
|
}
|
|
257
272
|
/** The view's lines before `end` as bare text, one per line, with no ids: what a summarizer sees as context (§4.2). */
|
|
258
273
|
export function bareLines(fold, tree, end) {
|
|
259
274
|
return fold.parts.filter(part => partStart(part) + span(part.level) <= end)
|
|
260
|
-
.map(part => flat(tree.text(part.level, part.index) ?? PLACEHOLDER)).join('\n');
|
|
275
|
+
.map(part => flat(part.open ? PLACEHOLDER : tree.text(part.level, part.index) ?? PLACEHOLDER)).join('\n');
|
|
261
276
|
}
|
|
262
277
|
/** zoom(id, n): line `id+n` opened into its two children, or message `id` whole when n is 1 (§7.1). */
|
|
263
278
|
export function zoomText(tree, id, n) {
|
package/dist/memory/optchat.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ type Failure = {
|
|
|
39
39
|
type StateDoc = {
|
|
40
40
|
calls: number;
|
|
41
41
|
usage: Ledger;
|
|
42
|
+
requests?: Ledger[];
|
|
43
|
+
sharedContext?: boolean;
|
|
42
44
|
failures: Record<string, Failure>;
|
|
43
45
|
};
|
|
44
46
|
/** Compactor bookkeeping of one conversation: calls, spend and the last failure of each node, including its retry streak. */
|
|
@@ -81,8 +83,11 @@ export interface OptChatOptions {
|
|
|
81
83
|
readonly agentName?: string;
|
|
82
84
|
readonly nodeBytes?: number;
|
|
83
85
|
readonly viewBytes?: number;
|
|
84
|
-
/**
|
|
86
|
+
/** Cold-start/callback context budget. Model jobs otherwise reuse the last turn view. Default 32 000 UTF-8 bytes. */
|
|
85
87
|
readonly compactBytes?: number;
|
|
88
|
+
/** Checkpoint high/low marks as fractions of both view budgets. Defaults 1.25 / 0.75; set both to 1 for strict budgets. */
|
|
89
|
+
readonly foldHighWater?: number;
|
|
90
|
+
readonly foldLowWater?: number;
|
|
86
91
|
/** Seed memories from the app brief or PM decisions. Captured at creation, stored once before this conversation's messages. */
|
|
87
92
|
readonly notes?: readonly string[];
|
|
88
93
|
/** Host classification of agent, subagent or job reports. A work message starts a turn like a user message. */
|
|
@@ -107,7 +112,7 @@ export interface OptChatStats {
|
|
|
107
112
|
/** Stored summaries whose fingerprint matches the log. */
|
|
108
113
|
readonly summaries: number;
|
|
109
114
|
readonly view: {
|
|
110
|
-
/** Bytes of the view's lines
|
|
115
|
+
/** Bytes of the view's lines, without the `<chat>` wrapper; checkpoints start above budget × foldHighWater. */
|
|
111
116
|
readonly bytes: number;
|
|
112
117
|
readonly budget: number;
|
|
113
118
|
/** Lines the next request would show, oldest first. `id` and `n` as in `id+n`. */
|
|
@@ -132,6 +137,8 @@ export interface OptChatStats {
|
|
|
132
137
|
readonly error?: string;
|
|
133
138
|
/** Summarizer calls made for this conversation (from the state document, so it survives a restart). */
|
|
134
139
|
readonly calls: number;
|
|
140
|
+
/** Latest 256 provider response usage records booked by settled tasks, including retries; retained across restart. Lifetime calls/usage are not truncated. Interrupted tasks and callback summarizers have no usage record. */
|
|
141
|
+
readonly requests: readonly Readonly<Ledger>[];
|
|
135
142
|
/** Spend of those calls (kept by this extension; not in Pi's `pi.usage`). */
|
|
136
143
|
readonly usage: {
|
|
137
144
|
readonly input: number;
|
|
@@ -150,7 +157,7 @@ export interface OptChatStats {
|
|
|
150
157
|
readonly logMessages: number;
|
|
151
158
|
readonly viewBytes: number;
|
|
152
159
|
readonly waitedMs: number;
|
|
153
|
-
/** False
|
|
160
|
+
/** False while the view still shows placeholders, including frozen ones from an earlier timeout. */
|
|
154
161
|
readonly settled: boolean;
|
|
155
162
|
};
|
|
156
163
|
}
|
|
@@ -167,7 +174,7 @@ export interface OptChatMemory {
|
|
|
167
174
|
date(conversationId: ConversationId, id: number): Promise<string>;
|
|
168
175
|
/** Forget a Pi context message by its zero-based position, including system positions. Atomically tombstones its leaves and deletes summaries that could depend on them, including through context. Does not delete Pi's session entry. */
|
|
169
176
|
forgetMessage(conversationId: ConversationId, messageIndex: number): Promise<void>;
|
|
170
|
-
/** Drop in-memory state of a conversation (stored summaries stay). */
|
|
177
|
+
/** Drop in-memory state of a conversation (stored summaries and the emitted tiling stay). */
|
|
171
178
|
forget(conversationId: ConversationId): void;
|
|
172
179
|
/** Stop this process's waits. Summary tasks belong to Pi, which keeps or recovers them; harmless to skip, and safe to call before closing the harness. */
|
|
173
180
|
dispose(): Promise<void>;
|
package/dist/memory/optchat.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { CompactionTask, GenerationTask, defineDoc, defineDocFamily, defineExtension, defineTask, defineTool, hook, section } from '@earendil-works/pi-durable';
|
|
11
11
|
import { withAbortSignal, withCancel } from '@earendil-works/chord/context';
|
|
12
12
|
import { Type } from '@earendil-works/pi-ai';
|
|
13
|
-
import { DEFAULTS, OptChatLog, OptChatTree, advanceFold, bareLines, byteLength, compactPrompt, cutBytes, dateText, emptyFold, leavesOf, nodeKey, openParts, partStart, renderView, scaleLine, span, viewDoc, zoomText, } from './optchat-core.js';
|
|
13
|
+
import { COMPACTOR_REQUEST_HISTORY_LIMIT, DEFAULTS, OptChatLog, OptChatTree, advanceFold, bareLines, byteLength, compactPrompt, cutBytes, dateText, emptyFold, leavesOf, nodeKey, openParts, partStart, renderView, scaleLine, span, viewDoc, zoomText, } from './optchat-core.js';
|
|
14
14
|
export { DEFAULTS as OPTCHAT_DEFAULTS, PLACEHOLDER as OPTCHAT_PLACEHOLDER, compactPrompt, viewDoc } from './optchat-core.js';
|
|
15
15
|
export { OptChatLog, OptChatTree, advanceFold, emptyFold, leavesOf, renderView, zoomText, dateText } from './optchat-core.js';
|
|
16
16
|
/** Summaries of one conversation, in shards of 64 nodes per level (`<level>.<shard>`). A fork starts with the current ones; fingerprints reject any that no longer match. */
|
|
@@ -27,6 +27,11 @@ export const OptChatEdits = defineDoc({
|
|
|
27
27
|
kind: 'boring.memory.optchat.edits', version: 1, scope: 'conversation', history: 'latest', fork: 'current',
|
|
28
28
|
initial: () => ({ revision: 0, forgotten: {} }),
|
|
29
29
|
});
|
|
30
|
+
/** Only the emitted tiling and fingerprints are retained, never another copy of message or summary text. */
|
|
31
|
+
const OptChatFold = defineDocFamily({
|
|
32
|
+
kind: 'boring.memory.optchat.fold', version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current',
|
|
33
|
+
initial: () => ({ configuration: '', revision: -1, fingerprints: [], fold: null }),
|
|
34
|
+
});
|
|
30
35
|
/** Compactor bookkeeping of one conversation: calls, spend and the last failure of each node, including its retry streak. */
|
|
31
36
|
export const OptChatState = defineDoc({
|
|
32
37
|
kind: 'boring.memory.optchat.state', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
|
|
@@ -59,6 +64,7 @@ class Session {
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
const message = (text) => ({ role: 'user', content: text.map(part => ({ type: 'text', text: part })), timestamp: Date.now() });
|
|
67
|
+
const copyFold = (fold) => ({ ...fold, parts: fold.parts.map(part => ({ ...part })) });
|
|
62
68
|
const LIVE = ['pending', 'running', 'waiting'];
|
|
63
69
|
function redacted(item) {
|
|
64
70
|
if (item.role === 'system')
|
|
@@ -104,6 +110,12 @@ export function createOptChatMemory(options) {
|
|
|
104
110
|
const nodeBytes = options.nodeBytes ?? DEFAULTS.nodeBytes, viewBytes = options.viewBytes ?? DEFAULTS.viewBytes, jobs = options.jobs ?? DEFAULTS.jobs;
|
|
105
111
|
const tries = options.tries ?? DEFAULTS.tries, cap = options.cap ?? DEFAULTS.cap, retryMs = options.retryMs ?? DEFAULTS.retryMs, settleMs = options.settleTimeoutMs ?? DEFAULTS.settleTimeoutMs;
|
|
106
112
|
const compactBytes = options.compactBytes ?? DEFAULTS.compactBytes;
|
|
113
|
+
const highWater = options.foldHighWater ?? DEFAULTS.foldHighWater, lowWater = options.foldLowWater ?? DEFAULTS.foldLowWater;
|
|
114
|
+
if (!Number.isFinite(highWater) || !Number.isFinite(lowWater) || lowWater <= 0 || lowWater > highWater)
|
|
115
|
+
throw new Error('Invalid OptChat fold watermarks');
|
|
116
|
+
const configuration = JSON.stringify([nodeBytes, viewBytes, compactBytes, highWater, lowWater, cap]);
|
|
117
|
+
const foldView = (fold, tree, to, budget) => advanceFold(fold, tree, to, budget, highWater, lowWater);
|
|
118
|
+
const missingParts = (fold, tree) => openParts(fold).filter(part => tree.text(part.level, part.index) === undefined);
|
|
107
119
|
const agentName = options.agentName ?? 'OptChat';
|
|
108
120
|
const extensionName = options.name ?? 'boring.memory.optchat';
|
|
109
121
|
const taskKind = `${extensionName}.node`;
|
|
@@ -113,6 +125,8 @@ export function createOptChatMemory(options) {
|
|
|
113
125
|
const logOptions = (seed, edits) => ({ notes: seed, forgotten: edits.forgotten, ...(options.workOf ? { workOf: options.workOf } : {}) });
|
|
114
126
|
const sessions = new Map();
|
|
115
127
|
const naps = new Map();
|
|
128
|
+
const warming = new Map();
|
|
129
|
+
const warmed = new Map();
|
|
116
130
|
const retryDelay = (failure) => Math.min(60_000, retryMs * 2 ** Math.min(30, (failure.attempts ?? 1) - 1));
|
|
117
131
|
const aborter = new AbortController();
|
|
118
132
|
const hostContext = withAbortSignal(aborter.signal, options.context);
|
|
@@ -142,11 +156,28 @@ export function createOptChatMemory(options) {
|
|
|
142
156
|
throw new Error(`No conversation ${String(id)}`);
|
|
143
157
|
return found;
|
|
144
158
|
}
|
|
145
|
-
/**
|
|
159
|
+
/** Earlier summary jobs take a prefix of the same tiling, never unfolding an already-sent checkpoint. */
|
|
146
160
|
function compactView(s, to) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
return foldView(copyFold(s.compactFold), s.tree, to, compactBytes);
|
|
162
|
+
}
|
|
163
|
+
async function restoreFold(s, revision, to, kind = 'turn') {
|
|
164
|
+
const saved = await options.harness().snapshot(OptChatFold, s.id, kind, options.context);
|
|
165
|
+
if (saved?.fold && saved.configuration === configuration && saved.revision === revision && saved.fold.T <= to
|
|
166
|
+
&& saved.fingerprints.every((fp, i) => { const part = saved.fold.parts[i]; return fp === s.log.fp(part.level, part.index); }))
|
|
167
|
+
s[kind === 'compact' ? 'compactFold' : 'fold'] = copyFold(saved.fold);
|
|
168
|
+
}
|
|
169
|
+
async function saveFold(s, revision, kind, fold, context) {
|
|
170
|
+
await (await conversationOf(s.id)).commit(async (tx) => {
|
|
171
|
+
if ((await tx.doc(OptChatEdits, s.id)).revision !== revision)
|
|
172
|
+
return;
|
|
173
|
+
const doc = await tx.doc(OptChatFold, s.id, kind, null);
|
|
174
|
+
if (kind === 'compact' && doc.configuration === configuration && doc.revision === revision && doc.fold && doc.fold.T > fold.T)
|
|
175
|
+
return;
|
|
176
|
+
doc.configuration = configuration;
|
|
177
|
+
doc.revision = revision;
|
|
178
|
+
doc.fold = copyFold(fold);
|
|
179
|
+
doc.fingerprints = fold.parts.map(part => s.log.fp(part.level, part.index));
|
|
180
|
+
}, context);
|
|
150
181
|
}
|
|
151
182
|
/** Bring a session up to date with the committed context and its stored summaries. */
|
|
152
183
|
async function refresh(id) {
|
|
@@ -154,8 +185,10 @@ export function createOptChatMemory(options) {
|
|
|
154
185
|
const view = await (await conversationOf(id)).context(options.context);
|
|
155
186
|
const seed = await seedNotes(id);
|
|
156
187
|
const edits = await memoryEdits(id);
|
|
157
|
-
s.sync(leavesOf(view.messages, cap, logOptions(seed, edits)).leaves);
|
|
188
|
+
s.sync(leavesOf(view.messages, cap, logOptions(seed, edits), s.log.leaves).leaves);
|
|
158
189
|
await loadStored(s, options.harness(), options.context);
|
|
190
|
+
await restoreFold(s, edits.revision, s.log.leaves.length);
|
|
191
|
+
await restoreFold(s, edits.revision, s.log.leaves.length, 'compact');
|
|
159
192
|
return s;
|
|
160
193
|
}
|
|
161
194
|
async function memoryEdits(id) {
|
|
@@ -176,7 +209,7 @@ export function createOptChatMemory(options) {
|
|
|
176
209
|
}
|
|
177
210
|
// ---- The summarizer (runs inside the native task) ----------------------------------------------------------------------
|
|
178
211
|
/** One node by the reference protocol (§4.2, §4.3): context first, then the step; over-long lines are resubmitted cut at the limit; the shortest wins. */
|
|
179
|
-
async function askModel(job, runtimeModels, signal) {
|
|
212
|
+
async function askModel(job, runtimeModels, signal, prefix = job.context) {
|
|
180
213
|
const summarizer = options.summarizer;
|
|
181
214
|
if ('summarize' in summarizer) {
|
|
182
215
|
try {
|
|
@@ -194,20 +227,53 @@ export function createOptChatMemory(options) {
|
|
|
194
227
|
if (!model)
|
|
195
228
|
throw new Error(`Summarizer model ${ref.provider}/${ref.modelId} is not available`);
|
|
196
229
|
const lines = job.source.map(line => line.replace(/\r?\n/g, ' '));
|
|
197
|
-
const
|
|
230
|
+
const prompt = `${system}\n\nFor scale, this line is exactly ${scale.bytes} bytes:\n${scale.text}`;
|
|
231
|
+
const step = job.level === 0
|
|
198
232
|
? `Compress this message into one line, in at most ${nodeBytes} bytes:\n${job.source[0]}`
|
|
199
|
-
: `Merge these two lines into one, in at most ${nodeBytes} bytes:\n${lines.join('\n')}
|
|
200
|
-
const conversation = [message([...chatBlocks(
|
|
233
|
+
: `Merge these two lines into one, in at most ${nodeBytes} bytes:\n${lines.join('\n')}`;
|
|
234
|
+
const conversation = [message([...chatBlocks(prefix), step])];
|
|
201
235
|
const attempts = [];
|
|
236
|
+
const requests = [];
|
|
202
237
|
let usage, calls = 0, spender;
|
|
238
|
+
const owner = `${String(job.conversationId)}:${ref.provider}/${ref.modelId}`;
|
|
239
|
+
const key = JSON.stringify([owner, prefix]);
|
|
240
|
+
let release;
|
|
203
241
|
try {
|
|
242
|
+
// Tasks already occupy the host's admission cap. Only the first request on a new prefix warms it.
|
|
243
|
+
while (warmed.get(owner) !== prefix) {
|
|
244
|
+
const pending = warming.get(key);
|
|
245
|
+
if (!pending) {
|
|
246
|
+
warming.set(key, new Promise(resolve => { release = resolve; }));
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (signal.aborted)
|
|
250
|
+
throw signal.reason;
|
|
251
|
+
let abort;
|
|
252
|
+
try {
|
|
253
|
+
await Promise.race([pending, new Promise((_resolve, reject) => {
|
|
254
|
+
abort = () => reject(signal.reason);
|
|
255
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
256
|
+
})]);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
if (abort)
|
|
260
|
+
signal.removeEventListener('abort', abort);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
204
263
|
for (;;) {
|
|
205
|
-
const reply = await models.complete(model, { systemPrompt:
|
|
264
|
+
const reply = await models.complete(model, { systemPrompt: prompt, messages: conversation }, { signal });
|
|
206
265
|
calls++;
|
|
207
266
|
spender = `${reply.provider}/${reply.model}`;
|
|
208
267
|
usage = addUsage(usage, reply.usage);
|
|
268
|
+
requests.push({ input: reply.usage.input, output: reply.usage.output, cacheRead: reply.usage.cacheRead, cacheWrite: reply.usage.cacheWrite, cost: reply.usage.cost.total });
|
|
209
269
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted')
|
|
210
270
|
throw new Error(reply.errorMessage ?? `Summarizer stopped: ${reply.stopReason}`);
|
|
271
|
+
warmed.set(owner, prefix);
|
|
272
|
+
if (release) {
|
|
273
|
+
warming.delete(key);
|
|
274
|
+
release();
|
|
275
|
+
release = undefined;
|
|
276
|
+
}
|
|
211
277
|
const line = reply.content.filter(part => part.type === 'text').map(part => part.text).join('\n').trim();
|
|
212
278
|
if (!line)
|
|
213
279
|
throw new Error('The summarizer returned nothing');
|
|
@@ -220,14 +286,24 @@ export function createOptChatMemory(options) {
|
|
|
220
286
|
catch (error) {
|
|
221
287
|
if (signal.aborted)
|
|
222
288
|
throw error;
|
|
223
|
-
return { error, ...(usage ? { usage } : {}), calls, ...(spender ? { spender } : {}) };
|
|
289
|
+
return { error, ...(usage ? { usage } : {}), calls, requests, ...(spender ? { spender } : {}) };
|
|
224
290
|
}
|
|
225
|
-
|
|
291
|
+
finally {
|
|
292
|
+
if (release) {
|
|
293
|
+
warming.delete(key);
|
|
294
|
+
release();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { text: attempts.reduce((best, line) => byteLength(line) < byteLength(best) ? line : best), ...(usage ? { usage } : {}), calls, requests, ...(spender ? { spender } : {}) };
|
|
226
298
|
}
|
|
227
299
|
/** What one node task adds to the conversation's documents in its single commit: calls, spend and the node or its failure. */
|
|
228
300
|
async function bookkeeping(tx, id, summary) {
|
|
229
301
|
const state = await tx.doc(OptChatState, id);
|
|
230
302
|
state.calls += summary.calls;
|
|
303
|
+
if (summary.requests?.length) {
|
|
304
|
+
state.requests = [...(state.requests ?? []), ...summary.requests].slice(-COMPACTOR_REQUEST_HISTORY_LIMIT);
|
|
305
|
+
state.sharedContext = true;
|
|
306
|
+
}
|
|
231
307
|
if (summary.usage && summary.spender) {
|
|
232
308
|
const u = summary.usage;
|
|
233
309
|
state.usage.input += u.input;
|
|
@@ -254,27 +330,31 @@ export function createOptChatMemory(options) {
|
|
|
254
330
|
const load = async () => {
|
|
255
331
|
const seed = (await runtime.snapshot(OptChatNotes, id, context))?.notes ?? [];
|
|
256
332
|
const edits = await runtime.snapshot(OptChatEdits, id, context) ?? { revision: 0, forgotten: {} };
|
|
257
|
-
s.sync(leavesOf((await runtime.context(id, context)).messages, cap, logOptions(seed, edits)).leaves);
|
|
333
|
+
s.sync(leavesOf((await runtime.context(id, context)).messages, cap, logOptions(seed, edits), s.log.leaves.length ? s.log.leaves : session(id).log.leaves).leaves);
|
|
258
334
|
await loadStored(s, runtime, context);
|
|
335
|
+
await restoreFold(s, edits.revision, s.log.leaves.length, 'compact');
|
|
259
336
|
return edits.revision;
|
|
260
337
|
};
|
|
261
338
|
const revision = await load();
|
|
262
339
|
const ready = () => s.log.fp(level, index) === of && (level === 0 || (s.tree.text(level - 1, 2 * index) !== undefined && s.tree.text(level - 1, 2 * index + 1) !== undefined));
|
|
263
|
-
if (!ready() || s.tree.text(level, index) !== undefined)
|
|
340
|
+
if ((index + 1) * span(level) > upTo || !ready() || s.tree.text(level, index) !== undefined)
|
|
264
341
|
return stale(); // the history changed, a child is missing, or the node needs no summary
|
|
265
342
|
const to = Math.min(upTo, index * span(level));
|
|
266
|
-
const shared = session(id);
|
|
267
|
-
shared.sync(s.log.leaves);
|
|
268
343
|
const fold = compactView(s, to);
|
|
269
|
-
if (fold.size > compactBytes)
|
|
344
|
+
if (fold.size > compactBytes * highWater || fold.checkpoint)
|
|
270
345
|
return stale(); // admission will build preceding parents before trying this node again
|
|
271
|
-
|
|
272
|
-
shared.compactFold = fold;
|
|
346
|
+
await saveFold(s, revision, 'compact', fold, context);
|
|
273
347
|
const source = level === 0 ? [s.log.leaves[index].line] : [s.tree.text(level - 1, 2 * index), s.tree.text(level - 1, 2 * index + 1)];
|
|
274
348
|
const job = { conversationId: id, level, index, range: [index * span(level), (index + 1) * span(level) - 1], source, context: bareLines(fold, s.tree, to), nodeBytes };
|
|
349
|
+
// Keep the callback's past-only context; model requests carry the last turn's full prefix, with the job after it.
|
|
350
|
+
await restoreFold(s, revision, s.log.leaves.length, 'bootstrap');
|
|
351
|
+
await restoreFold(s, revision, s.log.leaves.length);
|
|
352
|
+
const prefixFold = s.fold.stableThrough === undefined ? fold : s.fold;
|
|
353
|
+
await loadStored(s, runtime, context); // the selected turn may reference summaries committed since load()
|
|
354
|
+
const prefix = bareLines(prefixFold, s.tree, prefixFold.T);
|
|
275
355
|
let summary;
|
|
276
356
|
try {
|
|
277
|
-
summary = await askModel(job, runtime.models, runtime.signal);
|
|
357
|
+
summary = await askModel(job, runtime.models, runtime.signal, prefix);
|
|
278
358
|
}
|
|
279
359
|
catch (error) {
|
|
280
360
|
if (runtime.signal.aborted)
|
|
@@ -314,8 +394,8 @@ export function createOptChatMemory(options) {
|
|
|
314
394
|
/** Nodes that could be built now: sources exist and the whole preceding view is summarized (§4.1), oldest first. */
|
|
315
395
|
function candidates(s) {
|
|
316
396
|
const upTo = Math.min(s.upTo, s.log.leaves.length);
|
|
317
|
-
|
|
318
|
-
const open =
|
|
397
|
+
foldView(s.fold, s.tree, upTo, viewBytes);
|
|
398
|
+
const open = missingParts(s.fold, s.tree)[0];
|
|
319
399
|
const first = open ? partStart(open) : s.fold.T;
|
|
320
400
|
const found = [];
|
|
321
401
|
for (let level = 0; span(level) <= upTo; level++) {
|
|
@@ -327,7 +407,8 @@ export function createOptChatMemory(options) {
|
|
|
327
407
|
continue;
|
|
328
408
|
if (level > 0 && (s.tree.text(level - 1, 2 * index) === undefined || s.tree.text(level - 1, 2 * index + 1) === undefined))
|
|
329
409
|
continue;
|
|
330
|
-
|
|
410
|
+
const preceding = compactView(s, index * span(level));
|
|
411
|
+
if (preceding.size > compactBytes * highWater || preceding.checkpoint)
|
|
331
412
|
break;
|
|
332
413
|
found.push({ level, index });
|
|
333
414
|
}
|
|
@@ -344,6 +425,26 @@ export function createOptChatMemory(options) {
|
|
|
344
425
|
const failures = (await options.harness().snapshot(OptChatState, s.id, options.context))?.failures ?? {};
|
|
345
426
|
const now = Date.now(), upTo = Math.min(s.upTo, s.log.leaves.length);
|
|
346
427
|
const conversation = await conversationOf(s.id);
|
|
428
|
+
// Before the first turn there is no sent view. Freeze one cold prefix for the seed's native tasks.
|
|
429
|
+
if ('model' in options.summarizer) {
|
|
430
|
+
const revision = (await memoryEdits(s.id)).revision;
|
|
431
|
+
const saved = await options.harness().snapshot(OptChatFold, s.id, 'bootstrap', options.context);
|
|
432
|
+
if (!saved?.fold || saved.revision !== revision || saved.configuration !== configuration
|
|
433
|
+
|| !saved.fingerprints.every((fp, i) => fp === s.log.fp(saved.fold.parts[i].level, saved.fold.parts[i].index))) {
|
|
434
|
+
const prefix = emptyFold();
|
|
435
|
+
for (let i = 0; i + 4 <= s.fold.parts.length; i += 4) {
|
|
436
|
+
const block = s.fold.parts.slice(i, i + 4), bytes = block.reduce((sum, part) => sum + part.bytes, 0);
|
|
437
|
+
if (prefix.size + bytes > compactBytes * highWater)
|
|
438
|
+
break;
|
|
439
|
+
prefix.parts.push(...block.map(part => ({ ...part })));
|
|
440
|
+
prefix.size += bytes;
|
|
441
|
+
const last = block.at(-1);
|
|
442
|
+
prefix.T = partStart(last) + span(last.level);
|
|
443
|
+
}
|
|
444
|
+
prefix.stableThrough = prefix.T;
|
|
445
|
+
await saveFold(s, revision, 'bootstrap', prefix, options.context);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
347
448
|
let waiting = 0, created = 0;
|
|
348
449
|
const live = await conversation.commit(async (tx) => {
|
|
349
450
|
const found = new Map();
|
|
@@ -432,10 +533,12 @@ export function createOptChatMemory(options) {
|
|
|
432
533
|
const deadline = Date.now() + settleMs, skip = new Set();
|
|
433
534
|
for (;;) {
|
|
434
535
|
await loadStored(s, options.harness(), options.context);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
536
|
+
if (s.fold.stableThrough === undefined)
|
|
537
|
+
s.fold = emptyFold();
|
|
538
|
+
foldView(s.fold, s.tree, covered, viewBytes);
|
|
539
|
+
const open = missingParts(s.fold, s.tree).length;
|
|
540
|
+
if (open === 0 && s.fold.size <= viewBytes * highWater && !s.fold.checkpoint)
|
|
541
|
+
return openParts(s.fold).length === 0;
|
|
439
542
|
const { live } = await admit(s, skip);
|
|
440
543
|
if (open === 0 && live.size === 0)
|
|
441
544
|
return true;
|
|
@@ -449,7 +552,7 @@ export function createOptChatMemory(options) {
|
|
|
449
552
|
skip.add(done.key);
|
|
450
553
|
}
|
|
451
554
|
}
|
|
452
|
-
/** Context can copy
|
|
555
|
+
/** Context can copy facts across nodes. Shared-prefix model summaries may depend on any leaf, transitively. */
|
|
453
556
|
async function forgetMessage(id, messageIndex) {
|
|
454
557
|
if (!Number.isInteger(messageIndex) || messageIndex < 0)
|
|
455
558
|
throw new Error('Invalid message index');
|
|
@@ -473,8 +576,10 @@ export function createOptChatMemory(options) {
|
|
|
473
576
|
doc.revision++;
|
|
474
577
|
const state = await tx.doc(OptChatState, id);
|
|
475
578
|
for (let level = 0; span(level) <= leaves.length; level++) {
|
|
476
|
-
for (let index =
|
|
579
|
+
for (let index = 0; (index + 1) * span(level) <= leaves.length; index++) {
|
|
477
580
|
const shard = await tx.doc(OptChatNodes, id, shardKey(level, index), 0);
|
|
581
|
+
if (!state.sharedContext && index < Math.floor(start / span(level)))
|
|
582
|
+
continue;
|
|
478
583
|
delete shard.nodes[String(index)];
|
|
479
584
|
delete state.failures[nodeKey(level, index)];
|
|
480
585
|
}
|
|
@@ -515,18 +620,17 @@ export function createOptChatMemory(options) {
|
|
|
515
620
|
for (;;) {
|
|
516
621
|
const seed = await seedNotes(api.conversationId);
|
|
517
622
|
const edits = await memoryEdits(api.conversationId);
|
|
518
|
-
const
|
|
623
|
+
const cached = session(api.conversationId);
|
|
624
|
+
const { leaves, before } = leavesOf(request.messages, cap, logOptions(seed, edits), cached.log.leaves);
|
|
519
625
|
const last = request.messages.findLastIndex(item => item.role === 'user' || (item.role !== 'system' && options.workOf?.(item)));
|
|
520
626
|
const covered = last < 0 ? 0 : before[last];
|
|
521
627
|
if (covered === 0 && Object.keys(edits.forgotten).length === 0 && !request.messages.some(item => options.workOf?.(item)))
|
|
522
628
|
return undefined;
|
|
523
|
-
const cached = session(api.conversationId);
|
|
524
629
|
cached.sync(leaves);
|
|
525
630
|
const s = new Session(api.conversationId, nodeBytes);
|
|
526
631
|
s.sync(leaves);
|
|
527
632
|
s.upTo = covered;
|
|
528
|
-
|
|
529
|
-
s.fold = { T: cached.fold.T, size: cached.fold.size, parts: cached.fold.parts.map(part => ({ ...part })) };
|
|
633
|
+
await restoreFold(s, edits.revision, covered);
|
|
530
634
|
const started = Date.now();
|
|
531
635
|
const settled = await settle(s, covered, context);
|
|
532
636
|
if ((await memoryEdits(api.conversationId)).revision !== edits.revision)
|
|
@@ -556,6 +660,10 @@ export function createOptChatMemory(options) {
|
|
|
556
660
|
return [{ ...current, content: [...blocks, ...body] }];
|
|
557
661
|
});
|
|
558
662
|
cached.lastRequest = { at: new Date().toISOString(), covered, requestMessages: messages.length, logMessages: request.messages.length, viewBytes: byteLength(view), waitedMs: Date.now() - started, settled };
|
|
663
|
+
s.fold.stableThrough = s.fold.T;
|
|
664
|
+
await saveFold(s, edits.revision, 'turn', s.fold, context);
|
|
665
|
+
if ((await memoryEdits(api.conversationId)).revision !== edits.revision)
|
|
666
|
+
continue;
|
|
559
667
|
cached.fold = s.fold;
|
|
560
668
|
return { messages };
|
|
561
669
|
}
|
|
@@ -575,8 +683,8 @@ export function createOptChatMemory(options) {
|
|
|
575
683
|
nap: id => nap(id),
|
|
576
684
|
async stats(id) {
|
|
577
685
|
const s = await refresh(id);
|
|
578
|
-
const fold =
|
|
579
|
-
|
|
686
|
+
const fold = copyFold(s.fold);
|
|
687
|
+
foldView(fold, s.tree, s.log.leaves.length, viewBytes);
|
|
580
688
|
let summaries = 0;
|
|
581
689
|
for (const [key, node] of s.stored) {
|
|
582
690
|
const [level, index] = key.split(':').map(Number);
|
|
@@ -591,15 +699,17 @@ export function createOptChatMemory(options) {
|
|
|
591
699
|
return {
|
|
592
700
|
leaves: s.log.leaves.length, summaries,
|
|
593
701
|
view: { bytes: fold.size, budget: viewBytes, open: openParts(fold).length,
|
|
594
|
-
parts: fold.parts.map(part => ({ id: partStart(part), n: span(part.level), level: part.level, text: s.tree.text(part.level, part.index) ?? '', open: part.open })) },
|
|
595
|
-
compactor: { busy, retrying, failed: failures.length, idle: busy === 0 && retrying === 0, ...(latest ? { error: latest.error } : {}), calls: state?.calls ?? 0, usage: { ...(state?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }) } },
|
|
702
|
+
parts: fold.parts.map(part => ({ id: partStart(part), n: span(part.level), level: part.level, text: part.open ? '' : s.tree.text(part.level, part.index) ?? '', open: part.open })) },
|
|
703
|
+
compactor: { busy, retrying, failed: failures.length, idle: busy === 0 && retrying === 0, ...(latest ? { error: latest.error } : {}), calls: state?.calls ?? 0, requests: (state?.requests ?? []).slice(-COMPACTOR_REQUEST_HISTORY_LIMIT).map(request => ({ ...request })), usage: { ...(state?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }) } },
|
|
596
704
|
...(s.lastRequest ? { lastRequest: s.lastRequest } : {}),
|
|
597
705
|
};
|
|
598
706
|
},
|
|
599
707
|
async zoom(id, nth, n) { return zoomText((await refresh(id)).tree, nth, n); },
|
|
600
708
|
async date(id, nth) { return dateText((await refresh(id)).log, nth); },
|
|
601
709
|
forgetMessage,
|
|
602
|
-
forget(id) { sessions.delete(String(id));
|
|
710
|
+
forget(id) { sessions.delete(String(id)); for (const key of warmed.keys())
|
|
711
|
+
if (key.startsWith(`${String(id)}:`))
|
|
712
|
+
warmed.delete(key); },
|
|
603
713
|
async dispose() { aborter.abort(); await Promise.allSettled(naps.values()); },
|
|
604
714
|
};
|
|
605
715
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -147,15 +147,18 @@
|
|
|
147
147
|
}
|
|
148
148
|
},
|
|
149
149
|
"dependencies": {
|
|
150
|
-
"unpdf": "1.9.1"
|
|
150
|
+
"unpdf": "1.9.1"
|
|
151
|
+
},
|
|
152
|
+
"devDependencies": {
|
|
151
153
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
152
154
|
},
|
|
153
155
|
"peerDependencies": {
|
|
154
156
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
155
157
|
"@earendil-works/pi-ai": "~1.2.0",
|
|
156
158
|
"@earendil-works/chord": "~1.2.0",
|
|
157
|
-
"@hachej/boring-files": "^0.4.
|
|
158
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
159
|
+
"@hachej/boring-files": "^0.4.7",
|
|
160
|
+
"@hachej/boring-ui-kit": "^0.4.7",
|
|
161
|
+
"xlsx": "^0.20.3"
|
|
159
162
|
},
|
|
160
163
|
"peerDependenciesMeta": {
|
|
161
164
|
"@hachej/boring-files": {
|
|
@@ -163,6 +166,9 @@
|
|
|
163
166
|
},
|
|
164
167
|
"@hachej/boring-ui-kit": {
|
|
165
168
|
"optional": true
|
|
169
|
+
},
|
|
170
|
+
"xlsx": {
|
|
171
|
+
"optional": true
|
|
166
172
|
}
|
|
167
173
|
},
|
|
168
174
|
"engines": {
|