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/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 ALWAYS offered, so jev judges every
294
- * packed output — including logs whose failure lines the English FAILURE_SIGNAL regex cannot see. */
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
- export function excerptPickRequest(
335
- middleWindows: readonly ExcerptWindow[],
336
- headExcerpt: string,
337
- tailExcerpt: string,
338
- ): JevRequest | null {
339
- if (middleWindows.length === 0) return null;
340
- const criteria: Record<string, string> = {
341
- keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
342
- };
343
- const excerpts: Record<string, string> = { head_excerpt: headExcerpt, tail_excerpt: tailExcerpt };
344
- for (const window of middleWindows) {
345
- criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
346
- excerpts[window.id] = window.text;
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
- state: { excerpts },
350
- questions: {
351
- window: {
352
- type: 'choice',
353
- instructions: 'A large tool output 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?',
354
- criteria,
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, 'window', 'choice');
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'
@@ -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
  };
@@ -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. */
@@ -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
- const statuses = opts.statuses ?? {};
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 clampRatio(wFirst / (wFirst + wSecond));
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; contentHash: 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: async middle-window picker (jev-backed); null keeps the legacy head+tail halves. */
104
- export type PickMiddleExcerpt = (text: string, excerptBudgetBytes: number) => Promise<ExcerptMiddlePick | null>;
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
- pickMiddleExcerpt?: PickMiddleExcerpt;
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
- pickMiddleExcerpt: deps.pickMiddleExcerpt,
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
- pickMiddleExcerpt?: PickMiddleExcerpt;
154
- /** Prefetched middle excerpt (the batch path runs picks concurrently); null/undefined = none. */
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, pickMiddleExcerpt } = opts;
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
- : pickMiddleExcerpt
161
- ? (await pickMiddleExcerpt(text, obsConfig.excerptBytes)) ?? undefined
179
+ : pickMiddleExcerpts
180
+ ? (await pickMiddleExcerpts([{ obsId: opts.obsId, text }], obsConfig.excerptBytes))[0] ?? undefined
162
181
  : undefined;
163
- const { id: obsId, placeholder, contentHash } = buildPlaceholder(toolName, toolCallId, text, textBytes, obsConfig, middle);
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
- if (placeholderMemo.size >= MAX_MEMO_ENTRIES) {
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 tool result with text-only content that is not yet packed. */
202
- interface PackCandidate { toolName: string; toolCallId: string; text: string; textBytes: number; charLength: number }
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(messages: readonly unknown[], index: number, sessionRoot: string): PackCandidate | null {
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
- return { toolName, toolCallId, text, textBytes: Buffer.byteLength(text, 'utf8'), charLength };
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
- pickMiddleExcerpt?: PickMiddleExcerpt;
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, soft byte cap). Selection is
232
- // optimistic on bytes: a later store failure can only pack fewer items, never more than the caps.
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 || candidate.textBytes < obsConfig.thresholdBytes) continue;
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 — prefetch middle excerpts concurrently: every packed output asks jev, so a serial loop
245
- // would block onBeforeCompact for maxItems × (0.25–0.77s) per compaction.
246
- const picker = opts.pickMiddleExcerpt;
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 Promise.all(candidates.map((c) => picker(c.text, obsConfig.excerptBytes)))
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 offset = typeof params?.offset === 'number' ? params.offset : 0;
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=${offset} next_offset=${chunk.nextOffset} eof=${chunk.eof}]`,
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
- pickMiddleExcerpt?: PickMiddleExcerpt;
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 || candidate.textBytes < obsConfig.thresholdBytes) continue;
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
- pickMiddleExcerpt: deps.pickMiddleExcerpt,
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(