pi-pier 0.3.0 → 0.3.1

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.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -0,0 +1,475 @@
1
+ /**
2
+ * D101 ObservationPack plugin: the `obs_recall` tool plus the `context` interceptor projecting
3
+ * placeholders for large tool results after their first fullSends requests (JSONL stays intact).
4
+ * Cache-aware packing decisions, role visibility gates, append-only telemetry.
5
+ */
6
+ import type {
7
+ ContextEvent,
8
+ ContextEventResult,
9
+ ExtensionAPI,
10
+ ExtensionContext,
11
+ } from '@earendil-works/pi-coding-agent';
12
+ import { Type } from 'typebox';
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';
15
+ import { resolveEfficiencyConfig, type EfficiencyConfig, type ObservationPackConfig } from '../efficiency-config-core.ts';
16
+ import { resolveCacheRatioFromCost } from '../compact-economics-core.ts';
17
+ import { planToolGate, type RuntimeRoleManifest } from '../tool-gate.ts';
18
+ import { toolError } from '../tool-error.ts';
19
+
20
+ export const RECALL_TOOL_NAME = 'obs_recall';
21
+ const RECALL_MAX_BYTES = 16 * 1024;
22
+ const RECALL_MAX_LINES = 400;
23
+ const MAX_MEMO_ENTRIES = 256;
24
+ const MAX_BATCH_PACK_ITEMS = 20;
25
+ const MAX_BATCH_PACK_BYTES = 10 * 1024 * 1024;
26
+
27
+ const loggedPackedObsIds = new Set<string>();
28
+
29
+ interface MemoizedPlaceholder { placeholder: string; obsId: string }
30
+
31
+ const placeholderMemo = new Map<string, MemoizedPlaceholder>();
32
+
33
+ /** Subset of pi context messages this plugin reads; anything else is left untouched. */
34
+ interface ContextMessage { role?: unknown; isError?: unknown; toolName?: unknown; toolCallId?: unknown; content?: unknown }
35
+
36
+ /** Text blocks of a non-error, text-only tool result plus the joined-content length (blocks are
37
+ * joined with newlines). Null leaves the message untouched. Shared by the projection fast path and
38
+ * the compaction batch path so eligibility cannot drift. */
39
+ function toolResultContent(msg: unknown): { texts: string[]; charLength: number } | null {
40
+ const m = msg as ContextMessage | undefined;
41
+ if (!m || m.role !== 'toolResult' || m.isError) return null;
42
+ const content = m.content;
43
+ if (!Array.isArray(content) || content.length === 0) return null;
44
+ if (!content.every((b) => b && b.type === 'text' && typeof b.text === 'string')) return null;
45
+ const texts = (content as Array<{ text: string }>).map((b) => b.text);
46
+ return { texts, charLength: texts.length - 1 + texts.reduce((n, text) => n + text.length, 0) };
47
+ }
48
+
49
+ function buildPlaceholder(
50
+ toolName: string,
51
+ toolCallId: string,
52
+ text: string,
53
+ textBytes: number,
54
+ obsConfig: ObservationPackConfig,
55
+ middle?: ExcerptMiddlePick,
56
+ ): { id: string; placeholder: string; contentHash: string } {
57
+ const contentHash = sha256Hex(text);
58
+ const id = deriveObservationId(toolName, toolCallId, contentHash);
59
+ return {
60
+ id,
61
+ contentHash,
62
+ placeholder: formatObservationPlaceholder({
63
+ id,
64
+ toolName,
65
+ bytes: textBytes,
66
+ lines: countLines(text),
67
+ tokens: estimateTokens(text),
68
+ text,
69
+ fullSends: obsConfig.fullSends,
70
+ excerptBudget: obsConfig.excerptBytes,
71
+ middle,
72
+ }),
73
+ };
74
+ }
75
+
76
+ function memoKeyFor(sessionRoot: string, toolCallId: string, charLength: number): string {
77
+ return `${sessionRoot}:${toolCallId}:${charLength}`;
78
+ }
79
+
80
+ /** Append one observation.jsonl record; telemetry is always best-effort. */
81
+ async function logObservation(sessionRoot: string, fields: Record<string, unknown>): Promise<void> {
82
+ try {
83
+ await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'observation'), {
84
+ schema: 'pier-efficiency/1',
85
+ mechanism: 'observationPack',
86
+ ts: new Date().toISOString(),
87
+ ...fields,
88
+ });
89
+ } catch {
90
+ }
91
+ }
92
+
93
+ export function clearObservationMemoForTest(): void {
94
+ placeholderMemo.clear();
95
+ loggedPackedObsIds.clear();
96
+ }
97
+
98
+ interface ExcerptMiddlePick {
99
+ text: string;
100
+ label: string;
101
+ }
102
+
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>;
105
+
106
+ /** Build the OCC `onBeforeCompact` hook: role-gated, config-gated batch packing of the branch's
107
+ * large tool results before `ctx.compact()` rewrites the prefix. Exported so the gate behaviour is
108
+ * unit-testable without index.ts. */
109
+ export function createCompactionBatchPackHook(deps: {
110
+ getObsConfig: () => ObservationPackConfig;
111
+ getManifest?: () => RuntimeRoleManifest | null;
112
+ getSessionId?: () => string | undefined;
113
+ pickMiddleExcerpt?: PickMiddleExcerpt;
114
+ }): (sessionRoot: string, ctx: unknown) => Promise<void> {
115
+ return async (sessionRoot: string, ctx: unknown): Promise<void> => {
116
+ const obsConfig = deps.getObsConfig();
117
+ if (!obsConfig.enabled) return;
118
+
119
+ // Role gate: a role that cannot call obs_recall must not receive placeholders.
120
+ const manifest = deps.getManifest?.() ?? null;
121
+ if (manifest && planToolGate(RECALL_TOOL_NAME, manifest).kind === 'deny') return;
122
+
123
+ const branch = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
124
+ ?.sessionManager?.getBranch?.() ?? [];
125
+ const messages = branch.flatMap((entry) => {
126
+ if (!entry || typeof entry !== 'object') return [];
127
+ const e = entry as { type?: unknown; message?: unknown };
128
+ return e.type === 'message' && e.message ? [e.message] : [];
129
+ });
130
+
131
+ await batchPackObservations({
132
+ sessionRoot,
133
+ sessionId: deps.getSessionId?.(),
134
+ messages,
135
+ obsConfig,
136
+ pickMiddleExcerpt: deps.pickMiddleExcerpt,
137
+ });
138
+ };
139
+ }
140
+
141
+ /** Shared packing primitive: memo + telemetry live here so the projection and batch paths cannot
142
+ * diverge. Returns the placeholder, or null when the object could not be stored. */
143
+ async function packOneMessage(opts: {
144
+ sessionRoot: string;
145
+ toolName: string;
146
+ toolCallId: string;
147
+ text: string;
148
+ textBytes: number;
149
+ charLength: number;
150
+ obsConfig: ObservationPackConfig;
151
+ /** `extra` carries audit fields such as `source: 'compaction'`, `sendCount`, `tailTokensAfter`. */
152
+ 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. */
155
+ middle?: ExcerptMiddlePick | null;
156
+ }): Promise<string | null> {
157
+ const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, obsConfig, log, pickMiddleExcerpt } = opts;
158
+ const middle = opts.middle !== undefined
159
+ ? (opts.middle ?? undefined)
160
+ : pickMiddleExcerpt
161
+ ? (await pickMiddleExcerpt(text, obsConfig.excerptBytes)) ?? undefined
162
+ : undefined;
163
+ const { id: obsId, placeholder, contentHash } = buildPlaceholder(toolName, toolCallId, text, textBytes, obsConfig, middle);
164
+ const originalTokens = estimateTokens(text);
165
+ const placeholderTokens = estimateTokens(placeholder);
166
+
167
+ try {
168
+ await storeContentAddressedObject(observationObjectPath(sessionRoot, obsId), text, {
169
+ bytes: textBytes,
170
+ hash: contentHash,
171
+ lines: countLines(text),
172
+ });
173
+ } catch {
174
+ return null;
175
+ }
176
+
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 });
182
+
183
+ if (log?.logEnabled && !loggedPackedObsIds.has(obsId)) {
184
+ loggedPackedObsIds.add(obsId);
185
+ await logObservation(sessionRoot, {
186
+ event: log.event,
187
+ sessionId: log.sessionId ?? 'unknown',
188
+ obsId,
189
+ toolName,
190
+ originalBytes: textBytes,
191
+ originalTokens,
192
+ placeholderTokens,
193
+ grossSavedTokens: Math.max(0, originalTokens - placeholderTokens),
194
+ ...(log.extra ?? {}),
195
+ });
196
+ }
197
+
198
+ return placeholder;
199
+ }
200
+
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 }
203
+
204
+ function packCandidateAt(messages: readonly unknown[], index: number, sessionRoot: string): PackCandidate | null {
205
+ const msg = messages[index] as ContextMessage | undefined;
206
+ const content = toolResultContent(msg);
207
+ if (!content) return null;
208
+ const { texts, charLength } = content;
209
+ const text = texts.join('\n');
210
+ const toolName = typeof msg?.toolName === 'string' ? msg.toolName : 'tool';
211
+ const toolCallId = typeof msg?.toolCallId === 'string' ? msg.toolCallId : `call_${index}`;
212
+ if (placeholderMemo.has(memoKeyFor(sessionRoot, toolCallId, charLength))) return null;
213
+ if (containsReducerReceipt(text)) return null;
214
+ return { toolName, toolCallId, text, textBytes: Buffer.byteLength(text, 'utf8'), charLength };
215
+ }
216
+
217
+ export async function batchPackObservations(opts: {
218
+ sessionRoot: string;
219
+ sessionId?: string;
220
+ messages: readonly unknown[];
221
+ obsConfig: ObservationPackConfig;
222
+ limits?: { maxItems?: number; maxBytes?: number };
223
+ pickMiddleExcerpt?: PickMiddleExcerpt;
224
+ }): Promise<number> {
225
+ const { sessionRoot, sessionId, messages, obsConfig } = opts;
226
+ if (!obsConfig.enabled || !sessionRoot || !Array.isArray(messages)) return 0;
227
+
228
+ const maxItems = opts.limits?.maxItems ?? MAX_BATCH_PACK_ITEMS;
229
+ const maxBytes = opts.limits?.maxBytes ?? MAX_BATCH_PACK_BYTES;
230
+
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.
233
+ const candidates: PackCandidate[] = [];
234
+ let selectedBytes = 0;
235
+ 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;
238
+ if (selectedBytes + candidate.textBytes > maxBytes && candidates.length > 0) break;
239
+ selectedBytes += candidate.textBytes;
240
+ candidates.push(candidate);
241
+ }
242
+ if (candidates.length === 0) return 0;
243
+
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;
247
+ const middles = picker
248
+ ? await Promise.all(candidates.map((c) => picker(c.text, obsConfig.excerptBytes)))
249
+ : [];
250
+
251
+ let packedCount = 0;
252
+ for (let i = 0; i < candidates.length; i++) {
253
+ const candidate = candidates[i]!;
254
+ const packed = await packOneMessage({
255
+ sessionRoot,
256
+ ...candidate,
257
+ obsConfig,
258
+ log: { logEnabled: obsConfig.logEnabled, event: 'packed-batch', sessionId, extra: { source: 'compaction' } },
259
+ middle: picker ? (middles[i] ?? null) : undefined,
260
+ });
261
+ if (packed) packedCount++;
262
+ }
263
+ return packedCount;
264
+ }
265
+
266
+ async function runRecall(
267
+ params: { id?: unknown; offset?: unknown } | undefined,
268
+ ctx: ExtensionContext,
269
+ getConfig?: (ctx: ExtensionContext) => EfficiencyConfig,
270
+ ): Promise<{ content: Array<{ type: 'text'; text: string }>; details: Record<string, unknown> }> {
271
+ const rawId = params?.id;
272
+ if (typeof rawId !== 'string' || !isObservationId(rawId)) {
273
+ // A1: hard failures throw so pi flags isError for the model.
274
+ return toolError(`invalid observation id format: "${String(rawId)}"`);
275
+ }
276
+ const id = rawId;
277
+ const offset = typeof params?.offset === 'number' ? params.offset : 0;
278
+
279
+ const sessionDir = ctx?.sessionManager?.getSessionDir?.();
280
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
281
+ const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
282
+ if (!sessionRoot) return toolError('session storage is unavailable for observation recall.');
283
+
284
+ const startMs = Date.now();
285
+ const effConfig = getConfig ? getConfig(ctx) : resolveEfficiencyConfig();
286
+ try {
287
+ const chunk = await readStoredObjectChunk(observationObjectPath(sessionRoot, id), offset, {
288
+ maxBytes: effConfig.observationPack.recallChunkBytes ?? RECALL_MAX_BYTES,
289
+ maxLines: RECALL_MAX_LINES,
290
+ });
291
+
292
+ const header = [
293
+ `[obs_recall id=${id} offset=${offset} next_offset=${chunk.nextOffset} eof=${chunk.eof}]`,
294
+ `[chunk_bytes=${chunk.bytes} chunk_lines=${chunk.lines}; use next_offset to continue]`,
295
+ ].join('\n');
296
+
297
+ if (effConfig.observationPack.logEnabled) {
298
+ void logObservation(sessionRoot, {
299
+ event: 'recall',
300
+ sessionId: sessionId ?? 'unknown',
301
+ obsId: id,
302
+ offset,
303
+ chunkBytes: chunk.bytes,
304
+ lines: chunk.lines,
305
+ nextOffset: chunk.nextOffset,
306
+ eof: chunk.eof,
307
+ durationMs: Date.now() - startMs,
308
+ });
309
+ }
310
+
311
+ return {
312
+ content: [{ type: 'text', text: `${header}\n${chunk.text}` }],
313
+ details: {
314
+ id,
315
+ offset,
316
+ bytes: chunk.bytes,
317
+ lines: chunk.lines,
318
+ nextOffset: chunk.nextOffset,
319
+ eof: chunk.eof,
320
+ },
321
+ };
322
+ } catch (err) {
323
+ // Missing/corrupted object: drop the memo so a later send can repack this observation.
324
+ for (const [k, v] of placeholderMemo.entries()) {
325
+ if (v.obsId === id) placeholderMemo.delete(k);
326
+ }
327
+ return {
328
+ content: [
329
+ {
330
+ type: 'text',
331
+ text: `Error: failed to recall observation ${id}: ${err instanceof Error ? err.message : String(err)}`,
332
+ },
333
+ ],
334
+ details: {},
335
+ };
336
+ }
337
+ }
338
+
339
+ /** All deps optional: the plugin must work without efficiency config or jev. */
340
+ interface ObservationPackDeps {
341
+ getConfig?: (ctx: ExtensionContext) => EfficiencyConfig;
342
+ getRuntimeManifest?: () => RuntimeRoleManifest | null;
343
+ getRemainingHorizon?: () => number;
344
+ pickMiddleExcerpt?: PickMiddleExcerpt;
345
+ }
346
+
347
+ async function projectPlaceholders(
348
+ event: ContextEvent,
349
+ ctx: ExtensionContext,
350
+ deps: ObservationPackDeps,
351
+ ): Promise<ContextEventResult | undefined> {
352
+ if (!event || !Array.isArray(event.messages)) return undefined;
353
+
354
+ const effConfig = deps.getConfig ? deps.getConfig(ctx) : resolveEfficiencyConfig();
355
+ const obsConfig: ObservationPackConfig = effConfig.observationPack;
356
+ if (!obsConfig.enabled) return undefined;
357
+
358
+ const manifest = deps.getRuntimeManifest ? deps.getRuntimeManifest() : null;
359
+ if (manifest && planToolGate(RECALL_TOOL_NAME, manifest).kind === 'deny') return undefined;
360
+
361
+ const sessionDir = ctx?.sessionManager?.getSessionDir?.();
362
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
363
+ const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
364
+ if (!sessionRoot) return undefined; // Fail-open if session directory is absent.
365
+
366
+ // Only the projection fields are read; AgentMessage is structurally wider than ContextMessage.
367
+ const projected = [...event.messages] as unknown as ContextMessage[];
368
+ const len = projected.length;
369
+
370
+ // Assistant responses following each message = its send count.
371
+ const priorAssistantCounts = new Array<number>(len);
372
+ let assistantCount = 0;
373
+ for (let i = len - 1; i >= 0; i--) {
374
+ priorAssistantCounts[i] = assistantCount;
375
+ if (projected[i]?.role === 'assistant') assistantCount++;
376
+ }
377
+
378
+ for (let i = 0; i < len; i++) {
379
+ const msg = projected[i];
380
+ const content = toolResultContent(msg);
381
+ if (!content || !msg) continue;
382
+
383
+ const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : `call_${i}`;
384
+ // N1' fast path: the memo is keyed by session+call+content length, so it answers before the text
385
+ // join, the receipt scan, and the byte count — once packed, a message stays packed until eviction.
386
+ const memoKey = memoKeyFor(sessionRoot, toolCallId, content.charLength);
387
+ const memoized = placeholderMemo.get(memoKey);
388
+ if (memoized) {
389
+ placeholderMemo.delete(memoKey); // refresh LRU position
390
+ placeholderMemo.set(memoKey, memoized);
391
+ projected[i] = { ...msg, content: [{ type: 'text', text: memoized.placeholder }] };
392
+ continue;
393
+ }
394
+
395
+ const sendCount = priorAssistantCounts[i] ?? 0;
396
+ if (sendCount < obsConfig.fullSends) continue;
397
+
398
+ const candidate = packCandidateAt(projected, i, sessionRoot);
399
+ if (!candidate || candidate.textBytes < obsConfig.thresholdBytes) continue;
400
+
401
+ let tailTokensAfter = 0;
402
+ for (let j = i + 1; j < len; j++) {
403
+ const after = projected[j]?.content;
404
+ if (!Array.isArray(after)) continue;
405
+ for (const b of after as Array<{ type?: unknown; text?: unknown }>) {
406
+ if (b && b.type === 'text' && typeof b.text === 'string') tailTokensAfter += estimateTokens(b.text);
407
+ }
408
+ }
409
+
410
+ // Projected saving of the legacy placeholder drives the economic decision; packOneMessage
411
+ // recomputes the same placeholder when the answer is yes.
412
+ const originalTokens = estimateTokens(candidate.text);
413
+ const estimate = buildPlaceholder(candidate.toolName, candidate.toolCallId, candidate.text, candidate.textBytes, obsConfig);
414
+ const removedTokens = Math.max(0, originalTokens - estimateTokens(estimate.placeholder));
415
+ // Same resolution as OCC (provider family / token-account fallback): passing a raw 'auto' would
416
+ // let shouldPackForCache fall back on its own hardcoded ratio and diverge from compaction.
417
+ const cacheRatio = resolveCacheRatioFromCost(
418
+ effConfig.onlineContextCompact.cacheWriteReadRatio,
419
+ ctx.model?.cost,
420
+ { provider: ctx.model?.provider, modelId: ctx.model?.id },
421
+ );
422
+ const canPack = shouldPackForCache({
423
+ removedTokens,
424
+ tailTokensAfter,
425
+ expectedRemainingRequests: deps.getRemainingHorizon ? deps.getRemainingHorizon() : 4,
426
+ cacheWriteReadRatio: cacheRatio,
427
+ });
428
+ if (!canPack) continue;
429
+
430
+ const packed = await packOneMessage({
431
+ sessionRoot,
432
+ ...candidate,
433
+ obsConfig,
434
+ log: {
435
+ logEnabled: obsConfig.logEnabled,
436
+ event: 'packed',
437
+ sessionId,
438
+ extra: { sendCount, tailTokensAfter },
439
+ },
440
+ pickMiddleExcerpt: deps.pickMiddleExcerpt,
441
+ });
442
+ if (!packed) continue;
443
+ projected[i] = { ...msg, content: [{ type: 'text', text: packed }] };
444
+ }
445
+
446
+ // Projection only replaces whole text blocks, so the messages stay valid AgentMessages.
447
+ return { messages: projected as unknown as ContextEvent['messages'] };
448
+ }
449
+
450
+ export function registerObservationPack(deps: ObservationPackDeps & { pi: ExtensionAPI }): void {
451
+ const { pi } = deps;
452
+
453
+ pi.registerTool({
454
+ name: RECALL_TOOL_NAME,
455
+ label: 'Recall Observation',
456
+ 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
+ ],
462
+ parameters: Type.Object({
463
+ id: Type.String({ description: 'Observation ID from placeholder (e.g. obs_...)' }),
464
+ offset: Type.Optional(
465
+ Type.Integer({ minimum: 0, description: 'Byte offset to recall from (default 0)' }),
466
+ ),
467
+ }),
468
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
469
+ void toolCallId;
470
+ return runRecall(params, ctx, deps.getConfig);
471
+ },
472
+ });
473
+
474
+ pi.on('context', (event, ctx) => projectPlaceholders(event, ctx, deps));
475
+ }