@hachej/boring-agent 0.4.6 → 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 +5 -4
- package/dist/documents.js +7 -1
- package/dist/memory/optchat-core.d.ts +1 -0
- package/dist/memory/optchat-core.js +1 -0
- package/dist/memory/optchat.d.ts +5 -1
- package/dist/memory/optchat.js +90 -15
- 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,7 +114,7 @@ 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
|
| --- | --- |
|
|
@@ -141,7 +141,8 @@ Deviations, honestly:
|
|
|
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 });
|
|
@@ -20,6 +20,7 @@ export declare const DEFAULTS: {
|
|
|
20
20
|
/** How long a request waits for the summaries it needs. */
|
|
21
21
|
readonly settleTimeoutMs: 60000;
|
|
22
22
|
};
|
|
23
|
+
export declare const COMPACTOR_REQUEST_HISTORY_LIMIT = 256;
|
|
23
24
|
/** What a view line shows for a message that has no summary yet. Only a fail-safe: requests wait for summaries. */
|
|
24
25
|
export declare const PLACEHOLDER = "(not summarized yet: zoom it)";
|
|
25
26
|
/** UTF-8 length without allocating. */
|
|
@@ -19,6 +19,7 @@ export const DEFAULTS = {
|
|
|
19
19
|
/** How long a request waits for the summaries it needs. */
|
|
20
20
|
settleTimeoutMs: 60_000,
|
|
21
21
|
};
|
|
22
|
+
export const COMPACTOR_REQUEST_HISTORY_LIMIT = 256;
|
|
22
23
|
/** What a view line shows for a message that has no summary yet. Only a fail-safe: requests wait for summaries. */
|
|
23
24
|
export const PLACEHOLDER = '(not summarized yet: zoom it)';
|
|
24
25
|
/** UTF-8 length without allocating. */
|
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,7 +83,7 @@ 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;
|
|
86
88
|
/** Checkpoint high/low marks as fractions of both view budgets. Defaults 1.25 / 0.75; set both to 1 for strict budgets. */
|
|
87
89
|
readonly foldHighWater?: number;
|
|
@@ -135,6 +137,8 @@ export interface OptChatStats {
|
|
|
135
137
|
readonly error?: string;
|
|
136
138
|
/** Summarizer calls made for this conversation (from the state document, so it survives a restart). */
|
|
137
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>[];
|
|
138
142
|
/** Spend of those calls (kept by this extension; not in Pi's `pi.usage`). */
|
|
139
143
|
readonly usage: {
|
|
140
144
|
readonly input: number;
|
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. */
|
|
@@ -125,6 +125,8 @@ export function createOptChatMemory(options) {
|
|
|
125
125
|
const logOptions = (seed, edits) => ({ notes: seed, forgotten: edits.forgotten, ...(options.workOf ? { workOf: options.workOf } : {}) });
|
|
126
126
|
const sessions = new Map();
|
|
127
127
|
const naps = new Map();
|
|
128
|
+
const warming = new Map();
|
|
129
|
+
const warmed = new Map();
|
|
128
130
|
const retryDelay = (failure) => Math.min(60_000, retryMs * 2 ** Math.min(30, (failure.attempts ?? 1) - 1));
|
|
129
131
|
const aborter = new AbortController();
|
|
130
132
|
const hostContext = withAbortSignal(aborter.signal, options.context);
|
|
@@ -162,7 +164,7 @@ export function createOptChatMemory(options) {
|
|
|
162
164
|
const saved = await options.harness().snapshot(OptChatFold, s.id, kind, options.context);
|
|
163
165
|
if (saved?.fold && saved.configuration === configuration && saved.revision === revision && saved.fold.T <= to
|
|
164
166
|
&& saved.fingerprints.every((fp, i) => { const part = saved.fold.parts[i]; return fp === s.log.fp(part.level, part.index); }))
|
|
165
|
-
s[kind === '
|
|
167
|
+
s[kind === 'compact' ? 'compactFold' : 'fold'] = copyFold(saved.fold);
|
|
166
168
|
}
|
|
167
169
|
async function saveFold(s, revision, kind, fold, context) {
|
|
168
170
|
await (await conversationOf(s.id)).commit(async (tx) => {
|
|
@@ -207,7 +209,7 @@ export function createOptChatMemory(options) {
|
|
|
207
209
|
}
|
|
208
210
|
// ---- The summarizer (runs inside the native task) ----------------------------------------------------------------------
|
|
209
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. */
|
|
210
|
-
async function askModel(job, runtimeModels, signal) {
|
|
212
|
+
async function askModel(job, runtimeModels, signal, prefix = job.context) {
|
|
211
213
|
const summarizer = options.summarizer;
|
|
212
214
|
if ('summarize' in summarizer) {
|
|
213
215
|
try {
|
|
@@ -225,20 +227,53 @@ export function createOptChatMemory(options) {
|
|
|
225
227
|
if (!model)
|
|
226
228
|
throw new Error(`Summarizer model ${ref.provider}/${ref.modelId} is not available`);
|
|
227
229
|
const lines = job.source.map(line => line.replace(/\r?\n/g, ' '));
|
|
228
|
-
const
|
|
230
|
+
const prompt = `${system}\n\nFor scale, this line is exactly ${scale.bytes} bytes:\n${scale.text}`;
|
|
231
|
+
const step = job.level === 0
|
|
229
232
|
? `Compress this message into one line, in at most ${nodeBytes} bytes:\n${job.source[0]}`
|
|
230
|
-
: `Merge these two lines into one, in at most ${nodeBytes} bytes:\n${lines.join('\n')}
|
|
231
|
-
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])];
|
|
232
235
|
const attempts = [];
|
|
236
|
+
const requests = [];
|
|
233
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;
|
|
234
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
|
+
}
|
|
235
263
|
for (;;) {
|
|
236
|
-
const reply = await models.complete(model, { systemPrompt:
|
|
264
|
+
const reply = await models.complete(model, { systemPrompt: prompt, messages: conversation }, { signal });
|
|
237
265
|
calls++;
|
|
238
266
|
spender = `${reply.provider}/${reply.model}`;
|
|
239
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 });
|
|
240
269
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted')
|
|
241
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
|
+
}
|
|
242
277
|
const line = reply.content.filter(part => part.type === 'text').map(part => part.text).join('\n').trim();
|
|
243
278
|
if (!line)
|
|
244
279
|
throw new Error('The summarizer returned nothing');
|
|
@@ -251,14 +286,24 @@ export function createOptChatMemory(options) {
|
|
|
251
286
|
catch (error) {
|
|
252
287
|
if (signal.aborted)
|
|
253
288
|
throw error;
|
|
254
|
-
return { error, ...(usage ? { usage } : {}), calls, ...(spender ? { spender } : {}) };
|
|
289
|
+
return { error, ...(usage ? { usage } : {}), calls, requests, ...(spender ? { spender } : {}) };
|
|
255
290
|
}
|
|
256
|
-
|
|
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 } : {}) };
|
|
257
298
|
}
|
|
258
299
|
/** What one node task adds to the conversation's documents in its single commit: calls, spend and the node or its failure. */
|
|
259
300
|
async function bookkeeping(tx, id, summary) {
|
|
260
301
|
const state = await tx.doc(OptChatState, id);
|
|
261
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
|
+
}
|
|
262
307
|
if (summary.usage && summary.spender) {
|
|
263
308
|
const u = summary.usage;
|
|
264
309
|
state.usage.input += u.input;
|
|
@@ -292,7 +337,7 @@ export function createOptChatMemory(options) {
|
|
|
292
337
|
};
|
|
293
338
|
const revision = await load();
|
|
294
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));
|
|
295
|
-
if (!ready() || s.tree.text(level, index) !== undefined)
|
|
340
|
+
if ((index + 1) * span(level) > upTo || !ready() || s.tree.text(level, index) !== undefined)
|
|
296
341
|
return stale(); // the history changed, a child is missing, or the node needs no summary
|
|
297
342
|
const to = Math.min(upTo, index * span(level));
|
|
298
343
|
const fold = compactView(s, to);
|
|
@@ -301,9 +346,15 @@ export function createOptChatMemory(options) {
|
|
|
301
346
|
await saveFold(s, revision, 'compact', fold, context);
|
|
302
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)];
|
|
303
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);
|
|
304
355
|
let summary;
|
|
305
356
|
try {
|
|
306
|
-
summary = await askModel(job, runtime.models, runtime.signal);
|
|
357
|
+
summary = await askModel(job, runtime.models, runtime.signal, prefix);
|
|
307
358
|
}
|
|
308
359
|
catch (error) {
|
|
309
360
|
if (runtime.signal.aborted)
|
|
@@ -374,6 +425,26 @@ export function createOptChatMemory(options) {
|
|
|
374
425
|
const failures = (await options.harness().snapshot(OptChatState, s.id, options.context))?.failures ?? {};
|
|
375
426
|
const now = Date.now(), upTo = Math.min(s.upTo, s.log.leaves.length);
|
|
376
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
|
+
}
|
|
377
448
|
let waiting = 0, created = 0;
|
|
378
449
|
const live = await conversation.commit(async (tx) => {
|
|
379
450
|
const found = new Map();
|
|
@@ -481,7 +552,7 @@ export function createOptChatMemory(options) {
|
|
|
481
552
|
skip.add(done.key);
|
|
482
553
|
}
|
|
483
554
|
}
|
|
484
|
-
/** Context can copy
|
|
555
|
+
/** Context can copy facts across nodes. Shared-prefix model summaries may depend on any leaf, transitively. */
|
|
485
556
|
async function forgetMessage(id, messageIndex) {
|
|
486
557
|
if (!Number.isInteger(messageIndex) || messageIndex < 0)
|
|
487
558
|
throw new Error('Invalid message index');
|
|
@@ -505,8 +576,10 @@ export function createOptChatMemory(options) {
|
|
|
505
576
|
doc.revision++;
|
|
506
577
|
const state = await tx.doc(OptChatState, id);
|
|
507
578
|
for (let level = 0; span(level) <= leaves.length; level++) {
|
|
508
|
-
for (let index =
|
|
579
|
+
for (let index = 0; (index + 1) * span(level) <= leaves.length; index++) {
|
|
509
580
|
const shard = await tx.doc(OptChatNodes, id, shardKey(level, index), 0);
|
|
581
|
+
if (!state.sharedContext && index < Math.floor(start / span(level)))
|
|
582
|
+
continue;
|
|
510
583
|
delete shard.nodes[String(index)];
|
|
511
584
|
delete state.failures[nodeKey(level, index)];
|
|
512
585
|
}
|
|
@@ -627,14 +700,16 @@ export function createOptChatMemory(options) {
|
|
|
627
700
|
leaves: s.log.leaves.length, summaries,
|
|
628
701
|
view: { bytes: fold.size, budget: viewBytes, open: openParts(fold).length,
|
|
629
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 })) },
|
|
630
|
-
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 }) } },
|
|
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 }) } },
|
|
631
704
|
...(s.lastRequest ? { lastRequest: s.lastRequest } : {}),
|
|
632
705
|
};
|
|
633
706
|
},
|
|
634
707
|
async zoom(id, nth, n) { return zoomText((await refresh(id)).tree, nth, n); },
|
|
635
708
|
async date(id, nth) { return dateText((await refresh(id)).log, nth); },
|
|
636
709
|
forgetMessage,
|
|
637
|
-
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); },
|
|
638
713
|
async dispose() { aborter.abort(); await Promise.allSettled(naps.values()); },
|
|
639
714
|
};
|
|
640
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": {
|