pi-pier 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +16 -2
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +69 -85
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +43 -21
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +1 -13
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/vocab.ts +7 -1
package/src/jev-core.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { isPlainObject } from './guards.ts';
|
|
12
12
|
import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
|
|
13
|
+
import { completeLineExcerpt } from './observation-core.ts';
|
|
13
14
|
|
|
14
15
|
/* ── Request/response shapes (closed set) ── */
|
|
15
16
|
|
|
@@ -153,6 +154,8 @@ export interface DiagnosticGateVerdict {
|
|
|
153
154
|
readonly choice: string | null;
|
|
154
155
|
readonly noul: number | null;
|
|
155
156
|
}
|
|
157
|
+
/** Confidence is checked before the choice: an unconfident rejection is not authoritative, so the
|
|
158
|
+
* caller's regex list must decide (same fail-open path as a typed `low-confidence` answer). */
|
|
156
159
|
export function evaluateDiagnosticGate(
|
|
157
160
|
answers: Record<string, JevAnswer>,
|
|
158
161
|
minConfidence: number,
|
|
@@ -162,12 +165,12 @@ export function evaluateDiagnosticGate(
|
|
|
162
165
|
if (kind === undefined || output === undefined) {
|
|
163
166
|
return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
|
|
164
167
|
}
|
|
165
|
-
if (kind.choice !== 'build_test_run') {
|
|
166
|
-
return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
167
|
-
}
|
|
168
168
|
if (kind.confidence < minConfidence) {
|
|
169
169
|
return { hit: false, reason: 'low-confidence', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
170
170
|
}
|
|
171
|
+
if (kind.choice !== 'build_test_run') {
|
|
172
|
+
return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
173
|
+
}
|
|
171
174
|
if (output.noul < DIAGNOSTIC_OUTPUT_MIN_NOUL) {
|
|
172
175
|
return { hit: false, reason: 'non-diagnostic-output', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
|
|
173
176
|
}
|
|
@@ -262,9 +265,12 @@ export interface ExcerptWindow {
|
|
|
262
265
|
readonly id: ExcerptWindowId;
|
|
263
266
|
readonly label: string;
|
|
264
267
|
readonly text: string;
|
|
268
|
+
/** 0-based first line of the window in the packed output (telemetry joins picks to objects). */
|
|
269
|
+
readonly startLine: number;
|
|
265
270
|
}
|
|
266
271
|
|
|
267
272
|
interface LineWindow {
|
|
273
|
+
readonly start: number;
|
|
268
274
|
readonly text: string;
|
|
269
275
|
readonly bytes: number;
|
|
270
276
|
readonly signalCount: number;
|
|
@@ -286,12 +292,12 @@ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: n
|
|
|
286
292
|
if (signalLines.has(i)) signalCount++;
|
|
287
293
|
}
|
|
288
294
|
if (selected.length === 0) return null;
|
|
289
|
-
return { text: selected.join('\n'), bytes, signalCount };
|
|
295
|
+
return { start, text: selected.join('\n'), bytes, signalCount };
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
/** Code-owned middle-window candidates for a packed observation: failure-signal windows (first hit,
|
|
293
|
-
* densest cluster) when present, plus a plain mid-output window
|
|
294
|
-
*
|
|
299
|
+
* densest cluster) when present, plus a plain mid-output window. The picker asks jev only when a
|
|
300
|
+
* signal window exists; a mid-only set kept head/tail or failed in 8 of 10 asks (D123). */
|
|
295
301
|
export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
|
|
296
302
|
if (halfBudgetBytes <= 0) return [];
|
|
297
303
|
const lines = text.split('\n');
|
|
@@ -308,7 +314,7 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
|
|
|
308
314
|
const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
|
|
309
315
|
if (firstWindow !== null && !seen.has(firstWindow.text)) {
|
|
310
316
|
seen.add(firstWindow.text);
|
|
311
|
-
out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
|
|
317
|
+
out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text, startLine: firstWindow.start });
|
|
312
318
|
}
|
|
313
319
|
|
|
314
320
|
let best: LineWindow | null = null;
|
|
@@ -319,49 +325,80 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
|
|
|
319
325
|
}
|
|
320
326
|
if (best !== null && !seen.has(best.text)) {
|
|
321
327
|
seen.add(best.text);
|
|
322
|
-
out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
|
|
328
|
+
out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text, startLine: best.start });
|
|
323
329
|
}
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
const midAnchor = Math.min(Math.floor(lines.length / 2), Math.max(0, lines.length - 1));
|
|
327
333
|
const midWindow = buildLineWindow(lines, midAnchor, halfBudgetBytes, signalLines);
|
|
328
334
|
if (midWindow !== null && !seen.has(midWindow.text)) {
|
|
329
|
-
out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text });
|
|
335
|
+
out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text, startLine: midWindow.start });
|
|
330
336
|
}
|
|
331
337
|
return out;
|
|
332
338
|
}
|
|
333
339
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Which packed outputs are worth an excerpt ask, keeping each item's index into `items`. Skipped:
|
|
342
|
+
* outputs without a failure-signal window (only `mid` competes with head/tail; that lost in 8 of 10
|
|
343
|
+
* asks, D123) and outputs whose excerpts carry a credential shape (§8 privacy boundary).
|
|
344
|
+
*/
|
|
345
|
+
export function planExcerptAsks(
|
|
346
|
+
items: ReadonlyArray<{ obsId: string; text: string }>,
|
|
347
|
+
excerptBudgetBytes: number,
|
|
348
|
+
): Array<{ index: number; obsId: string } & ExcerptPickItem> {
|
|
349
|
+
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
350
|
+
const asks: Array<{ index: number; obsId: string } & ExcerptPickItem> = [];
|
|
351
|
+
for (let index = 0; index < items.length; index++) {
|
|
352
|
+
const { obsId, text } = items[index]!;
|
|
353
|
+
const windows = buildExcerptWindows(text, halfBudget);
|
|
354
|
+
if (!windows.some((w) => w.id !== 'mid')) continue;
|
|
355
|
+
const headExcerpt = completeLineExcerpt(text, halfBudget, false);
|
|
356
|
+
const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
|
|
357
|
+
if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) continue;
|
|
358
|
+
asks.push({ index, obsId, windows, headExcerpt, tailExcerpt });
|
|
347
359
|
}
|
|
348
|
-
return
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
360
|
+
return asks;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ExcerptPickItem {
|
|
364
|
+
readonly windows: readonly ExcerptWindow[];
|
|
365
|
+
readonly headExcerpt: string;
|
|
366
|
+
readonly tailExcerpt: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** One request for every packed output (a compaction batch packs up to 20): one round trip instead of
|
|
370
|
+
* a 20-way burst. `outputs[i]` carries item i's excerpts; its question is `excerpt_${i}_window`. */
|
|
371
|
+
export function excerptPickRequest(items: readonly ExcerptPickItem[]): JevRequest | null {
|
|
372
|
+
if (items.length === 0) return null;
|
|
373
|
+
const questions: Record<string, JevQuestion> = {};
|
|
374
|
+
const outputs: Array<Record<string, string>> = [];
|
|
375
|
+
for (let i = 0; i < items.length; i++) {
|
|
376
|
+
const item = items[i]!;
|
|
377
|
+
const criteria: Record<string, string> = {
|
|
378
|
+
keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
|
|
379
|
+
};
|
|
380
|
+
const excerpts: Record<string, string> = { head_excerpt: item.headExcerpt, tail_excerpt: item.tailExcerpt };
|
|
381
|
+
for (const window of item.windows) {
|
|
382
|
+
criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
|
|
383
|
+
excerpts[window.id] = window.text;
|
|
384
|
+
}
|
|
385
|
+
outputs.push(excerpts);
|
|
386
|
+
// Question keys are NOT sent to the model, so the instruction names its output index.
|
|
387
|
+
questions[`excerpt_${i}_window`] = {
|
|
388
|
+
type: 'choice',
|
|
389
|
+
instructions: `A large tool output (outputs[${i}]) was replaced by a placeholder keeping two short excerpts. Which excerpt set would most help a coding agent locate the problem without recalling the full text?`,
|
|
390
|
+
criteria,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return { state: { outputs }, questions };
|
|
358
394
|
}
|
|
359
395
|
|
|
360
396
|
export function evaluateExcerptPick(
|
|
361
397
|
answers: Record<string, JevAnswer>,
|
|
398
|
+
index: number,
|
|
362
399
|
minConfidence: number,
|
|
363
400
|
): ExcerptWindowId | null {
|
|
364
|
-
const picked = answerOf(answers,
|
|
401
|
+
const picked = answerOf(answers, `excerpt_${index}_window`, 'choice');
|
|
365
402
|
if (picked === undefined) return null;
|
|
366
403
|
if (picked.confidence < minConfidence) return null;
|
|
367
404
|
return picked.choice === 'first_signal' || picked.choice === 'densest' || picked.choice === 'mid'
|
package/src/observation-core.ts
CHANGED
|
@@ -138,6 +138,8 @@ export interface RecallSliceResult {
|
|
|
138
138
|
text: string;
|
|
139
139
|
bytes: number;
|
|
140
140
|
lines: number;
|
|
141
|
+
/** Byte offset the returned text actually starts at (after line-start alignment). */
|
|
142
|
+
offset: number;
|
|
141
143
|
nextOffset: number;
|
|
142
144
|
eof: boolean;
|
|
143
145
|
}
|
|
@@ -157,7 +159,7 @@ export function sliceBufferChunk(
|
|
|
157
159
|
limits: { maxBytes: number; maxLines: number },
|
|
158
160
|
): RecallSliceResult {
|
|
159
161
|
if (offset >= buf.length) {
|
|
160
|
-
return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
|
|
162
|
+
return { text: '', bytes: 0, lines: 0, offset, nextOffset: buf.length, eof: true };
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
let end = Math.min(buf.length - offset, limits.maxBytes);
|
|
@@ -169,6 +171,15 @@ export function sliceBufferChunk(
|
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
// A byte-cap cut mid-line would make the next page (and its aligned next_offset) skip the rest of
|
|
175
|
+
// that line; end this page at the previous line boundary instead. A line longer than maxBytes has
|
|
176
|
+
// no boundary to snap to and stays byte-sliced.
|
|
177
|
+
const cappedAt = offset + end;
|
|
178
|
+
if (cappedAt < buf.length && buf[cappedAt - 1] !== 0x0a) {
|
|
179
|
+
const lastNewline = buf.lastIndexOf(0x0a, cappedAt - 1);
|
|
180
|
+
if (lastNewline >= offset) end = lastNewline + 1 - offset;
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
const chunkBuf = buf.subarray(offset, offset + trimUtf8End(buf.subarray(offset), end));
|
|
173
184
|
const text = chunkBuf.toString('utf8');
|
|
174
185
|
const nextOffset = offset + chunkBuf.length;
|
|
@@ -176,6 +187,7 @@ export function sliceBufferChunk(
|
|
|
176
187
|
text,
|
|
177
188
|
bytes: chunkBuf.length,
|
|
178
189
|
lines: countLines(text),
|
|
190
|
+
offset,
|
|
179
191
|
nextOffset,
|
|
180
192
|
eof: nextOffset >= buf.length,
|
|
181
193
|
};
|
package/src/pipe-channel.ts
CHANGED
|
@@ -58,14 +58,6 @@ export type PipeRequest =
|
|
|
58
58
|
id: string;
|
|
59
59
|
role: string;
|
|
60
60
|
}
|
|
61
|
-
| {
|
|
62
|
-
/** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress report. The master
|
|
63
|
-
* injects it as a display-less custom message (no turn, no visible chat noise). */
|
|
64
|
-
type: 'progress';
|
|
65
|
-
id: string;
|
|
66
|
-
paneId: string;
|
|
67
|
-
text: string;
|
|
68
|
-
}
|
|
69
61
|
| {
|
|
70
62
|
/** D113 (RFC rfc-unified-widget): worker → master full todo push on every todo_write, so the
|
|
71
63
|
* master's resident widget and /pier-tree render live worker state without polling herdr. */
|
package/src/plugins/heat-plan.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type ShapeNode } from './grid-shape.ts';
|
|
|
10
10
|
|
|
11
11
|
export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
|
|
12
12
|
const RATIO_FLOOR = 0.10;
|
|
13
|
+
const NON_AGENT_RATIO_FLOOR = 0.25;
|
|
13
14
|
const FOCUS_SHARE = 0.72;
|
|
14
15
|
const FOCUS_SHARE_BLOCKED = 0.60;
|
|
15
16
|
const MAX_AUTO_LAYOUT_PANES = 10;
|
|
@@ -57,6 +58,19 @@ function clampRatio(r: number): number {
|
|
|
57
58
|
return Math.min(1 - RATIO_FLOOR, Math.max(RATIO_FLOOR, r));
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Agent-less panes (plain shells report no status or `unknown`) keep NON_AGENT_RATIO_FLOOR in mixed splits. */
|
|
62
|
+
function hasAgentPanes(node: LayoutNode, statuses: AgentStatusMap): boolean {
|
|
63
|
+
return flattenPanes(node).some((id) => statuses[id] !== undefined && statuses[id] !== 'unknown');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function splitRatio(node: SplitNode, ratio: number, statuses: AgentStatusMap): number {
|
|
67
|
+
const firstAgent = hasAgentPanes(node.first, statuses);
|
|
68
|
+
const secondAgent = hasAgentPanes(node.second, statuses);
|
|
69
|
+
if (firstAgent && !secondAgent) ratio = Math.min(ratio, 1 - NON_AGENT_RATIO_FLOOR);
|
|
70
|
+
else if (!firstAgent && secondAgent) ratio = Math.max(ratio, NON_AGENT_RATIO_FLOOR);
|
|
71
|
+
return clampRatio(ratio);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function tierWeight(status: string | undefined, isAsk = false, slim = false): number {
|
|
61
75
|
if (status === 'blocked') return isAsk ? ASK_WEIGHT : BLOCKED_WEIGHT;
|
|
62
76
|
if (status === 'working') return slim ? WORKING_WEIGHT * SLIM_FACTOR : WORKING_WEIGHT;
|
|
@@ -129,7 +143,8 @@ export function planSpawnSplitRatio(opts: {
|
|
|
129
143
|
if (paneCount <= 1 || paneCount > MAX_AUTO_LAYOUT_PANES) return null;
|
|
130
144
|
if (!containsPane(simulated, opts.focusPaneId) || !containsPane(simulated, newId)) return null;
|
|
131
145
|
|
|
132
|
-
|
|
146
|
+
// The new pane is an agent pane; `idle` weighs the same as a missing status.
|
|
147
|
+
const statuses: AgentStatusMap = { [newId]: 'idle', ...opts.statuses };
|
|
133
148
|
const askFlags = opts.askFlags ?? {};
|
|
134
149
|
const slim = paneCount - 1 >= SLIM_THRESHOLD;
|
|
135
150
|
const focusSteps = findPath(simulated, opts.focusPaneId);
|
|
@@ -148,11 +163,11 @@ export function planSpawnSplitRatio(opts: {
|
|
|
148
163
|
const r = clampRatio(target ** (1 / focusSteps.length));
|
|
149
164
|
const at = focusSteps.find((s) => s.node === newSplit);
|
|
150
165
|
if (!at) return null;
|
|
151
|
-
return at.side === 'first' ? r : 1 - r;
|
|
166
|
+
return splitRatio(newSplit, at.side === 'first' ? r : 1 - r, statuses);
|
|
152
167
|
}
|
|
153
168
|
|
|
154
169
|
const wFirst = weightOf(newSplit.first, statuses, askFlags, slim);
|
|
155
170
|
const wSecond = weightOf(newSplit.second, statuses, askFlags, slim);
|
|
156
171
|
if (wFirst + wSecond <= 0) return null;
|
|
157
|
-
return
|
|
172
|
+
return splitRatio(newSplit, wFirst / (wFirst + wSecond), statuses);
|
|
158
173
|
}
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from '@earendil-works/pi-coding-agent';
|
|
12
12
|
import { Type } from 'typebox';
|
|
13
13
|
import { containsReducerReceipt, countLines, deriveObservationId, estimateTokens, formatObservationPlaceholder, isObservationId, sha256Hex, shouldPackForCache } from '../observation-core.ts';
|
|
14
|
-
import { appendEfficiencyLog, efficiencyLogPath, observationObjectPath, readStoredObjectChunk, resolveSessionRoot, storeContentAddressedObject } from '../efficiency-store.ts';
|
|
14
|
+
import { appendEfficiencyLog, efficiencyLogPath, observationObjectExists, observationObjectPath, readStoredObjectChunk, resolveSessionRoot, storeContentAddressedObject } from '../efficiency-store.ts';
|
|
15
15
|
import { resolveEfficiencyConfig, type EfficiencyConfig, type ObservationPackConfig } from '../efficiency-config-core.ts';
|
|
16
16
|
import { resolveCacheRatioFromCost } from '../compact-economics-core.ts';
|
|
17
17
|
import { planToolGate, type RuntimeRoleManifest } from '../tool-gate.ts';
|
|
@@ -51,14 +51,13 @@ function buildPlaceholder(
|
|
|
51
51
|
toolCallId: string,
|
|
52
52
|
text: string,
|
|
53
53
|
textBytes: number,
|
|
54
|
+
contentHash: string,
|
|
54
55
|
obsConfig: ObservationPackConfig,
|
|
55
56
|
middle?: ExcerptMiddlePick,
|
|
56
|
-
): { id: string; placeholder: string
|
|
57
|
-
const contentHash = sha256Hex(text);
|
|
57
|
+
): { id: string; placeholder: string } {
|
|
58
58
|
const id = deriveObservationId(toolName, toolCallId, contentHash);
|
|
59
59
|
return {
|
|
60
60
|
id,
|
|
61
|
-
contentHash,
|
|
62
61
|
placeholder: formatObservationPlaceholder({
|
|
63
62
|
id,
|
|
64
63
|
toolName,
|
|
@@ -77,6 +76,20 @@ function memoKeyFor(sessionRoot: string, toolCallId: string, charLength: number)
|
|
|
77
76
|
return `${sessionRoot}:${toolCallId}:${charLength}`;
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
function rememberPlaceholder(
|
|
80
|
+
sessionRoot: string,
|
|
81
|
+
toolCallId: string,
|
|
82
|
+
charLength: number,
|
|
83
|
+
placeholder: string,
|
|
84
|
+
obsId: string,
|
|
85
|
+
): void {
|
|
86
|
+
if (placeholderMemo.size >= MAX_MEMO_ENTRIES) {
|
|
87
|
+
const oldest = placeholderMemo.keys().next().value;
|
|
88
|
+
if (oldest) placeholderMemo.delete(oldest);
|
|
89
|
+
}
|
|
90
|
+
placeholderMemo.set(memoKeyFor(sessionRoot, toolCallId, charLength), { placeholder, obsId });
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/** Append one observation.jsonl record; telemetry is always best-effort. */
|
|
81
94
|
async function logObservation(sessionRoot: string, fields: Record<string, unknown>): Promise<void> {
|
|
82
95
|
try {
|
|
@@ -100,8 +113,12 @@ interface ExcerptMiddlePick {
|
|
|
100
113
|
label: string;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
/** P0-3 seam:
|
|
104
|
-
|
|
116
|
+
/** P0-3 seam: batched middle-window picker (jev-backed), one result per item in order; null keeps
|
|
117
|
+
* the legacy head+tail halves. A whole compaction batch is one call. */
|
|
118
|
+
export type PickMiddleExcerpts = (
|
|
119
|
+
items: ReadonlyArray<{ obsId: string; text: string }>,
|
|
120
|
+
excerptBudgetBytes: number,
|
|
121
|
+
) => Promise<ReadonlyArray<ExcerptMiddlePick | null>>;
|
|
105
122
|
|
|
106
123
|
/** Build the OCC `onBeforeCompact` hook: role-gated, config-gated batch packing of the branch's
|
|
107
124
|
* large tool results before `ctx.compact()` rewrites the prefix. Exported so the gate behaviour is
|
|
@@ -110,7 +127,7 @@ export function createCompactionBatchPackHook(deps: {
|
|
|
110
127
|
getObsConfig: () => ObservationPackConfig;
|
|
111
128
|
getManifest?: () => RuntimeRoleManifest | null;
|
|
112
129
|
getSessionId?: () => string | undefined;
|
|
113
|
-
|
|
130
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
114
131
|
}): (sessionRoot: string, ctx: unknown) => Promise<void> {
|
|
115
132
|
return async (sessionRoot: string, ctx: unknown): Promise<void> => {
|
|
116
133
|
const obsConfig = deps.getObsConfig();
|
|
@@ -133,7 +150,7 @@ export function createCompactionBatchPackHook(deps: {
|
|
|
133
150
|
sessionId: deps.getSessionId?.(),
|
|
134
151
|
messages,
|
|
135
152
|
obsConfig,
|
|
136
|
-
|
|
153
|
+
pickMiddleExcerpts: deps.pickMiddleExcerpts,
|
|
137
154
|
});
|
|
138
155
|
};
|
|
139
156
|
}
|
|
@@ -147,20 +164,22 @@ async function packOneMessage(opts: {
|
|
|
147
164
|
text: string;
|
|
148
165
|
textBytes: number;
|
|
149
166
|
charLength: number;
|
|
167
|
+
contentHash: string;
|
|
168
|
+
obsId: string;
|
|
150
169
|
obsConfig: ObservationPackConfig;
|
|
151
170
|
/** `extra` carries audit fields such as `source: 'compaction'`, `sendCount`, `tailTokensAfter`. */
|
|
152
171
|
log?: { logEnabled: boolean; event: 'packed' | 'packed-batch'; sessionId?: string; extra?: Record<string, unknown> };
|
|
153
|
-
|
|
154
|
-
/** Prefetched middle excerpt (the batch path
|
|
172
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
173
|
+
/** Prefetched middle excerpt (the batch path picks for all items in one call); null/undefined = none. */
|
|
155
174
|
middle?: ExcerptMiddlePick | null;
|
|
156
175
|
}): Promise<string | null> {
|
|
157
|
-
const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, obsConfig, log,
|
|
176
|
+
const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, contentHash, obsConfig, log, pickMiddleExcerpts } = opts;
|
|
158
177
|
const middle = opts.middle !== undefined
|
|
159
178
|
? (opts.middle ?? undefined)
|
|
160
|
-
:
|
|
161
|
-
? (await
|
|
179
|
+
: pickMiddleExcerpts
|
|
180
|
+
? (await pickMiddleExcerpts([{ obsId: opts.obsId, text }], obsConfig.excerptBytes))[0] ?? undefined
|
|
162
181
|
: undefined;
|
|
163
|
-
const { id: obsId, placeholder
|
|
182
|
+
const { id: obsId, placeholder } = buildPlaceholder(toolName, toolCallId, text, textBytes, contentHash, obsConfig, middle);
|
|
164
183
|
const originalTokens = estimateTokens(text);
|
|
165
184
|
const placeholderTokens = estimateTokens(placeholder);
|
|
166
185
|
|
|
@@ -174,11 +193,7 @@ async function packOneMessage(opts: {
|
|
|
174
193
|
return null;
|
|
175
194
|
}
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
const oldest = placeholderMemo.keys().next().value;
|
|
179
|
-
if (oldest) placeholderMemo.delete(oldest);
|
|
180
|
-
}
|
|
181
|
-
placeholderMemo.set(memoKeyFor(sessionRoot, toolCallId, charLength), { placeholder, obsId });
|
|
196
|
+
rememberPlaceholder(sessionRoot, toolCallId, charLength, placeholder, obsId);
|
|
182
197
|
|
|
183
198
|
if (log?.logEnabled && !loggedPackedObsIds.has(obsId)) {
|
|
184
199
|
loggedPackedObsIds.add(obsId);
|
|
@@ -198,20 +213,65 @@ async function packOneMessage(opts: {
|
|
|
198
213
|
return placeholder;
|
|
199
214
|
}
|
|
200
215
|
|
|
201
|
-
/** One packable message: a non-error
|
|
202
|
-
interface PackCandidate {
|
|
216
|
+
/** One packable message: a non-error, text-only tool result over the size threshold. */
|
|
217
|
+
interface PackCandidate {
|
|
218
|
+
toolName: string;
|
|
219
|
+
toolCallId: string;
|
|
220
|
+
text: string;
|
|
221
|
+
textBytes: number;
|
|
222
|
+
charLength: number;
|
|
223
|
+
contentHash: string;
|
|
224
|
+
obsId: string;
|
|
225
|
+
}
|
|
203
226
|
|
|
204
|
-
function packCandidateAt(
|
|
227
|
+
function packCandidateAt(
|
|
228
|
+
messages: readonly unknown[],
|
|
229
|
+
index: number,
|
|
230
|
+
sessionRoot: string,
|
|
231
|
+
minBytes: number,
|
|
232
|
+
): PackCandidate | null {
|
|
205
233
|
const msg = messages[index] as ContextMessage | undefined;
|
|
206
234
|
const content = toolResultContent(msg);
|
|
207
235
|
if (!content) return null;
|
|
208
236
|
const { texts, charLength } = content;
|
|
209
237
|
const text = texts.join('\n');
|
|
238
|
+
const textBytes = Buffer.byteLength(text, 'utf8');
|
|
239
|
+
if (textBytes < minBytes) return null;
|
|
210
240
|
const toolName = typeof msg?.toolName === 'string' ? msg.toolName : 'tool';
|
|
211
241
|
const toolCallId = typeof msg?.toolCallId === 'string' ? msg.toolCallId : `call_${index}`;
|
|
212
242
|
if (placeholderMemo.has(memoKeyFor(sessionRoot, toolCallId, charLength))) return null;
|
|
213
243
|
if (containsReducerReceipt(text)) return null;
|
|
214
|
-
|
|
244
|
+
const contentHash = sha256Hex(text);
|
|
245
|
+
return {
|
|
246
|
+
toolName,
|
|
247
|
+
toolCallId,
|
|
248
|
+
text,
|
|
249
|
+
textBytes,
|
|
250
|
+
charLength,
|
|
251
|
+
contentHash,
|
|
252
|
+
obsId: deriveObservationId(toolName, toolCallId, contentHash),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A resumed process starts with an empty memo, but the content-addressed object is still on disk.
|
|
257
|
+
* Rebuild the deterministic placeholder from it instead of asking jev again, rewriting the object,
|
|
258
|
+
* and logging the same saving twice. Returns null when the object is not (verifiably) there. */
|
|
259
|
+
async function reusePackedObservation(
|
|
260
|
+
candidate: PackCandidate,
|
|
261
|
+
sessionRoot: string,
|
|
262
|
+
obsConfig: ObservationPackConfig,
|
|
263
|
+
): Promise<string | null> {
|
|
264
|
+
if (!(await observationObjectExists(sessionRoot, candidate.obsId, candidate.textBytes))) return null;
|
|
265
|
+
const { placeholder } = buildPlaceholder(
|
|
266
|
+
candidate.toolName,
|
|
267
|
+
candidate.toolCallId,
|
|
268
|
+
candidate.text,
|
|
269
|
+
candidate.textBytes,
|
|
270
|
+
candidate.contentHash,
|
|
271
|
+
obsConfig,
|
|
272
|
+
);
|
|
273
|
+
rememberPlaceholder(sessionRoot, candidate.toolCallId, candidate.charLength, placeholder, candidate.obsId);
|
|
274
|
+
return placeholder;
|
|
215
275
|
}
|
|
216
276
|
|
|
217
277
|
export async function batchPackObservations(opts: {
|
|
@@ -220,7 +280,7 @@ export async function batchPackObservations(opts: {
|
|
|
220
280
|
messages: readonly unknown[];
|
|
221
281
|
obsConfig: ObservationPackConfig;
|
|
222
282
|
limits?: { maxItems?: number; maxBytes?: number };
|
|
223
|
-
|
|
283
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
224
284
|
}): Promise<number> {
|
|
225
285
|
const { sessionRoot, sessionId, messages, obsConfig } = opts;
|
|
226
286
|
if (!obsConfig.enabled || !sessionRoot || !Array.isArray(messages)) return 0;
|
|
@@ -228,24 +288,25 @@ export async function batchPackObservations(opts: {
|
|
|
228
288
|
const maxItems = opts.limits?.maxItems ?? MAX_BATCH_PACK_ITEMS;
|
|
229
289
|
const maxBytes = opts.limits?.maxBytes ?? MAX_BATCH_PACK_BYTES;
|
|
230
290
|
|
|
231
|
-
// Pass 1 — select with the deterministic checks (memo, receipt, size,
|
|
232
|
-
//
|
|
291
|
+
// Pass 1 — select with the deterministic checks (memo, receipt, size, existing store object, soft
|
|
292
|
+
// byte cap). A reused object is memoized here, so no jev ask, write, or telemetry follows for it.
|
|
233
293
|
const candidates: PackCandidate[] = [];
|
|
234
294
|
let selectedBytes = 0;
|
|
235
295
|
for (let i = 0; i < messages.length && candidates.length < maxItems; i++) {
|
|
236
|
-
const candidate = packCandidateAt(messages, i, sessionRoot);
|
|
237
|
-
if (!candidate
|
|
296
|
+
const candidate = packCandidateAt(messages, i, sessionRoot, obsConfig.thresholdBytes);
|
|
297
|
+
if (!candidate) continue;
|
|
298
|
+
if (await reusePackedObservation(candidate, sessionRoot, obsConfig)) continue;
|
|
238
299
|
if (selectedBytes + candidate.textBytes > maxBytes && candidates.length > 0) break;
|
|
239
300
|
selectedBytes += candidate.textBytes;
|
|
240
301
|
candidates.push(candidate);
|
|
241
302
|
}
|
|
242
303
|
if (candidates.length === 0) return 0;
|
|
243
304
|
|
|
244
|
-
// Pass 2 —
|
|
245
|
-
//
|
|
246
|
-
const picker = opts.
|
|
305
|
+
// Pass 2 — one batched pick for every selected output: per-item asks were a 6-20-way burst that
|
|
306
|
+
// all failed together when jev was down.
|
|
307
|
+
const picker = opts.pickMiddleExcerpts;
|
|
247
308
|
const middles = picker
|
|
248
|
-
? await
|
|
309
|
+
? await picker(candidates.map((c) => ({ obsId: c.obsId, text: c.text })), obsConfig.excerptBytes)
|
|
249
310
|
: [];
|
|
250
311
|
|
|
251
312
|
let packedCount = 0;
|
|
@@ -274,7 +335,11 @@ async function runRecall(
|
|
|
274
335
|
return toolError(`invalid observation id format: "${String(rawId)}"`);
|
|
275
336
|
}
|
|
276
337
|
const id = rawId;
|
|
277
|
-
const
|
|
338
|
+
const rawOffset = params?.offset;
|
|
339
|
+
if (rawOffset !== undefined && (typeof rawOffset !== 'number' || !Number.isInteger(rawOffset) || rawOffset < 0)) {
|
|
340
|
+
return toolError(`invalid observation offset: "${String(rawOffset)}"`);
|
|
341
|
+
}
|
|
342
|
+
const offset = rawOffset ?? 0;
|
|
278
343
|
|
|
279
344
|
const sessionDir = ctx?.sessionManager?.getSessionDir?.();
|
|
280
345
|
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
@@ -288,9 +353,12 @@ async function runRecall(
|
|
|
288
353
|
maxBytes: effConfig.observationPack.recallChunkBytes ?? RECALL_MAX_BYTES,
|
|
289
354
|
maxLines: RECALL_MAX_LINES,
|
|
290
355
|
});
|
|
356
|
+
// Mid-line offsets are advanced to the next line start by the reader; the header and telemetry
|
|
357
|
+
// report the offset the returned chunk actually starts at.
|
|
358
|
+
const alignedOffset = chunk.offset;
|
|
291
359
|
|
|
292
360
|
const header = [
|
|
293
|
-
`[obs_recall id=${id} offset=${
|
|
361
|
+
`[obs_recall id=${id} offset=${alignedOffset} next_offset=${chunk.nextOffset} eof=${chunk.eof}]`,
|
|
294
362
|
`[chunk_bytes=${chunk.bytes} chunk_lines=${chunk.lines}; use next_offset to continue]`,
|
|
295
363
|
].join('\n');
|
|
296
364
|
|
|
@@ -299,7 +367,7 @@ async function runRecall(
|
|
|
299
367
|
event: 'recall',
|
|
300
368
|
sessionId: sessionId ?? 'unknown',
|
|
301
369
|
obsId: id,
|
|
302
|
-
offset,
|
|
370
|
+
offset: alignedOffset,
|
|
303
371
|
chunkBytes: chunk.bytes,
|
|
304
372
|
lines: chunk.lines,
|
|
305
373
|
nextOffset: chunk.nextOffset,
|
|
@@ -312,7 +380,7 @@ async function runRecall(
|
|
|
312
380
|
content: [{ type: 'text', text: `${header}\n${chunk.text}` }],
|
|
313
381
|
details: {
|
|
314
382
|
id,
|
|
315
|
-
offset,
|
|
383
|
+
offset: alignedOffset,
|
|
316
384
|
bytes: chunk.bytes,
|
|
317
385
|
lines: chunk.lines,
|
|
318
386
|
nextOffset: chunk.nextOffset,
|
|
@@ -341,7 +409,7 @@ interface ObservationPackDeps {
|
|
|
341
409
|
getConfig?: (ctx: ExtensionContext) => EfficiencyConfig;
|
|
342
410
|
getRuntimeManifest?: () => RuntimeRoleManifest | null;
|
|
343
411
|
getRemainingHorizon?: () => number;
|
|
344
|
-
|
|
412
|
+
pickMiddleExcerpts?: PickMiddleExcerpts;
|
|
345
413
|
}
|
|
346
414
|
|
|
347
415
|
async function projectPlaceholders(
|
|
@@ -395,8 +463,16 @@ async function projectPlaceholders(
|
|
|
395
463
|
const sendCount = priorAssistantCounts[i] ?? 0;
|
|
396
464
|
if (sendCount < obsConfig.fullSends) continue;
|
|
397
465
|
|
|
398
|
-
const candidate = packCandidateAt(projected, i, sessionRoot);
|
|
399
|
-
if (!candidate
|
|
466
|
+
const candidate = packCandidateAt(projected, i, sessionRoot, obsConfig.thresholdBytes);
|
|
467
|
+
if (!candidate) continue;
|
|
468
|
+
|
|
469
|
+
// Resume: the object survived the restart even though the memo did not. Project the same
|
|
470
|
+
// deterministic placeholder without re-asking jev or re-logging the saving.
|
|
471
|
+
const reused = await reusePackedObservation(candidate, sessionRoot, obsConfig);
|
|
472
|
+
if (reused) {
|
|
473
|
+
projected[i] = { ...msg, content: [{ type: 'text', text: reused }] };
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
400
476
|
|
|
401
477
|
let tailTokensAfter = 0;
|
|
402
478
|
for (let j = i + 1; j < len; j++) {
|
|
@@ -410,7 +486,7 @@ async function projectPlaceholders(
|
|
|
410
486
|
// Projected saving of the legacy placeholder drives the economic decision; packOneMessage
|
|
411
487
|
// recomputes the same placeholder when the answer is yes.
|
|
412
488
|
const originalTokens = estimateTokens(candidate.text);
|
|
413
|
-
const estimate = buildPlaceholder(candidate.toolName, candidate.toolCallId, candidate.text, candidate.textBytes, obsConfig);
|
|
489
|
+
const estimate = buildPlaceholder(candidate.toolName, candidate.toolCallId, candidate.text, candidate.textBytes, candidate.contentHash, obsConfig);
|
|
414
490
|
const removedTokens = Math.max(0, originalTokens - estimateTokens(estimate.placeholder));
|
|
415
491
|
// Same resolution as OCC (provider family / token-account fallback): passing a raw 'auto' would
|
|
416
492
|
// let shouldPackForCache fall back on its own hardcoded ratio and diverge from compaction.
|
|
@@ -437,7 +513,7 @@ async function projectPlaceholders(
|
|
|
437
513
|
sessionId,
|
|
438
514
|
extra: { sendCount, tailTokensAfter },
|
|
439
515
|
},
|
|
440
|
-
|
|
516
|
+
pickMiddleExcerpts: deps.pickMiddleExcerpts,
|
|
441
517
|
});
|
|
442
518
|
if (!packed) continue;
|
|
443
519
|
projected[i] = { ...msg, content: [{ type: 'text', text: packed }] };
|
|
@@ -454,11 +530,7 @@ export function registerObservationPack(deps: ObservationPackDeps & { pi: Extens
|
|
|
454
530
|
name: RECALL_TOOL_NAME,
|
|
455
531
|
label: 'Recall Observation',
|
|
456
532
|
description:
|
|
457
|
-
'Recall a paged slice of a previously replaced large tool result by observation id and byte offset.',
|
|
458
|
-
promptGuidelines: [
|
|
459
|
-
'Call obs_recall with id and offset to inspect specific parts of large tool outputs.',
|
|
460
|
-
'Check the returned next_offset and eof to page through long logs.',
|
|
461
|
-
],
|
|
533
|
+
'Recall a paged slice of a previously replaced large tool result by observation id and byte offset; page on with the returned next_offset until eof.',
|
|
462
534
|
parameters: Type.Object({
|
|
463
535
|
id: Type.String({ description: 'Observation ID from placeholder (e.g. obs_...)' }),
|
|
464
536
|
offset: Type.Optional(
|