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
@@ -1,555 +0,0 @@
1
- /**
2
- * D101 ObservationPack Plugin & Context Interceptor.
3
- *
4
- * Provides:
5
- * - `obs_recall` tool for paged retrieval of replaced large tool results.
6
- * - `context` lifecycle interception: projects placeholders for large tool results
7
- * that have exceeded fullSends, keeping underlying JSONL intact.
8
- * - Cache-aware packing decisions and role visibility guards.
9
- * - Append-only telemetry auditing.
10
- */
11
-
12
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
13
- import { Type } from 'typebox';
14
- import {
15
- containsReducerReceipt,
16
- countLines,
17
- deriveObservationId,
18
- estimateTokens,
19
- formatObservationPlaceholder,
20
- isObservationId,
21
- sha256Hex,
22
- shouldPackForCache,
23
- } from '../observation-core.ts';
24
- import {
25
- appendEfficiencyLog,
26
- efficiencyLogPath,
27
- observationObjectPath,
28
- readStoredObjectChunk,
29
- resolveSessionRoot,
30
- storeContentAddressedObject,
31
- } from '../efficiency-store.ts';
32
- import {
33
- resolveEfficiencyConfig,
34
- type EfficiencyConfig,
35
- type ObservationPackConfig,
36
- } from '../efficiency-config-core.ts';
37
- import { resolveCacheRatioFromCost } from '../compact-economics-core.ts';
38
- import { planToolGate, type RuntimeRoleManifest } from '../tool-gate.ts';
39
- import { toolError } from '../tool-error.ts';
40
-
41
- export const RECALL_TOOL_NAME = 'obs_recall';
42
- export const RECALL_MAX_BYTES = 16 * 1024;
43
- export const RECALL_MAX_LINES = 400;
44
-
45
- const loggedPackedObsIds = new Set<string>();
46
-
47
- interface MemoizedPlaceholder {
48
- placeholder: string;
49
- obsId: string;
50
- bytes: number;
51
- }
52
-
53
- const placeholderMemo = new Map<string, MemoizedPlaceholder>();
54
- const MAX_MEMO_ENTRIES = 256;
55
-
56
- export function contentJoinedLength(content: Array<{ text?: string }>): number {
57
- if (content.length === 0) return 0;
58
- let len = content.length - 1; // newline separators between blocks
59
- for (let i = 0; i < content.length; i++) {
60
- len += content[i]?.text?.length ?? 0;
61
- }
62
- return len;
63
- }
64
-
65
- export function memoKeyFor(sessionRoot: string, toolCallId: string, charLength: number): string {
66
- return `${sessionRoot}:${toolCallId}:${charLength}`;
67
- }
68
-
69
- export function invalidateObservationMemo(obsId: string): void {
70
- for (const [k, v] of placeholderMemo.entries()) {
71
- if (v.obsId === obsId) {
72
- placeholderMemo.delete(k);
73
- }
74
- }
75
- }
76
-
77
- export function clearObservationMemoForTest(): void {
78
- placeholderMemo.clear();
79
- loggedPackedObsIds.clear();
80
- }
81
-
82
- export const MAX_BATCH_PACK_ITEMS = 20;
83
- export const MAX_BATCH_PACK_BYTES = 10 * 1024 * 1024; // 10MB limit per batch
84
-
85
- /**
86
- * Build the OCC `onBeforeCompact` hook: role-gated, config-gated batch packing of the
87
- * branch's large tool results before `ctx.compact()` rewrites the prefix.
88
- * Exported (rather than inlined in index.ts) so the gate behaviour is unit-testable.
89
- */
90
- export interface ExcerptMiddlePick {
91
- text: string;
92
- label: string;
93
- }
94
-
95
- /** P0-3 seam: async middle-window picker (jev-backed); null keeps the legacy halves. */
96
- export type PickMiddleExcerpt = (text: string, excerptBudgetBytes: number) => Promise<ExcerptMiddlePick | null>;
97
-
98
- export function createCompactionBatchPackHook(deps: {
99
- getObsConfig: () => ObservationPackConfig;
100
- getManifest?: () => RuntimeRoleManifest | null;
101
- getSessionId?: () => string | undefined;
102
- pickMiddleExcerpt?: PickMiddleExcerpt;
103
- }): (sessionRoot: string, ctx: unknown) => Promise<void> {
104
- return async (sessionRoot: string, ctx: unknown): Promise<void> => {
105
- const obsConfig = deps.getObsConfig();
106
- if (!obsConfig.enabled) return;
107
-
108
- // Role gate: a role that cannot call obs_recall must not receive placeholders.
109
- const manifest = deps.getManifest?.() ?? null;
110
- if (manifest && planToolGate(RECALL_TOOL_NAME, manifest).kind === 'deny') return;
111
-
112
- const branch = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })?.sessionManager?.getBranch?.() ?? [];
113
- const messages = branch.flatMap((entry) => {
114
- if (!entry || typeof entry !== 'object') return [];
115
- const e = entry as { type?: unknown; message?: unknown };
116
- return e.type === 'message' && e.message ? [e.message] : [];
117
- });
118
-
119
- const sessionId = deps.getSessionId?.();
120
- await batchPackObservations({
121
- sessionRoot,
122
- sessionId,
123
- messages,
124
- obsConfig,
125
- pickMiddleExcerpt: deps.pickMiddleExcerpt,
126
- });
127
- };
128
- }
129
-
130
- export interface PackMessageResult {
131
- obsId: string;
132
- placeholder: string;
133
- originalBytes: number;
134
- originalTokens: number;
135
- placeholderTokens: number;
136
- grossSavedTokens: number;
137
- }
138
-
139
- export interface PackLogOptions {
140
- logEnabled: boolean;
141
- event: 'packed' | 'packed-batch';
142
- sessionId?: string;
143
- /** Extra audit fields (e.g. `source: 'compaction'`, `sendCount`, `tailTokensAfter`). */
144
- extra?: Record<string, unknown>;
145
- }
146
-
147
- /**
148
- * Shared packing primitive: derive id/placeholder, archive the object, memoize it, and append
149
- * the `observation.jsonl` record exactly once. Keeping memo + telemetry in one place prevents the
150
- * "wrote the memo but forgot the log" (or vice versa) divergence between the projection path and
151
- * the compaction batch path.
152
- */
153
- export async function packOneMessage(opts: {
154
- sessionRoot: string;
155
- toolName: string;
156
- toolCallId: string;
157
- text: string;
158
- textBytes: number;
159
- charLength: number;
160
- obsConfig: ObservationPackConfig;
161
- log?: PackLogOptions;
162
- pickMiddleExcerpt?: PickMiddleExcerpt;
163
- }): Promise<PackMessageResult | null> {
164
- const { sessionRoot, toolName, toolCallId, text, textBytes, charLength, obsConfig, log, pickMiddleExcerpt } = opts;
165
- const contentHash = sha256Hex(text);
166
- const obsId = deriveObservationId(toolName, toolCallId, contentHash);
167
- const originalTokens = estimateTokens(text);
168
- const lines = countLines(text);
169
- const middle = pickMiddleExcerpt
170
- ? (await pickMiddleExcerpt(text, obsConfig.excerptBytes)) ?? undefined
171
- : undefined;
172
- const placeholder = formatObservationPlaceholder({
173
- id: obsId,
174
- toolName,
175
- bytes: textBytes,
176
- lines,
177
- tokens: originalTokens,
178
- text,
179
- fullSends: obsConfig.fullSends,
180
- excerptBudget: obsConfig.excerptBytes,
181
- middle,
182
- });
183
- const placeholderTokens = estimateTokens(placeholder);
184
- const removedTokens = Math.max(0, originalTokens - placeholderTokens);
185
-
186
- const objPath = observationObjectPath(sessionRoot, obsId);
187
- try {
188
- await storeContentAddressedObject(objPath, text, {
189
- bytes: textBytes,
190
- hash: contentHash,
191
- lines,
192
- });
193
- } catch {
194
- return null;
195
- }
196
-
197
- const memoKey = memoKeyFor(sessionRoot, toolCallId, charLength);
198
- if (placeholderMemo.size >= MAX_MEMO_ENTRIES) {
199
- const oldest = placeholderMemo.keys().next().value;
200
- if (oldest) placeholderMemo.delete(oldest);
201
- }
202
- placeholderMemo.set(memoKey, {
203
- placeholder,
204
- obsId,
205
- bytes: textBytes,
206
- });
207
-
208
- if (log?.logEnabled && !loggedPackedObsIds.has(obsId)) {
209
- loggedPackedObsIds.add(obsId);
210
- try {
211
- await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'observation'), {
212
- schema: 'pier-efficiency/1',
213
- mechanism: 'observationPack',
214
- event: log.event,
215
- ts: new Date().toISOString(),
216
- sessionId: log.sessionId ?? 'unknown',
217
- obsId,
218
- toolName,
219
- originalBytes: textBytes,
220
- originalTokens,
221
- placeholderTokens,
222
- grossSavedTokens: removedTokens,
223
- ...(log.extra ?? {}),
224
- });
225
- } catch {
226
- /* telemetry is best-effort */
227
- }
228
- }
229
-
230
- return {
231
- obsId,
232
- placeholder,
233
- originalBytes: textBytes,
234
- originalTokens,
235
- placeholderTokens,
236
- grossSavedTokens: removedTokens,
237
- };
238
- }
239
-
240
- export async function batchPackObservations(opts: {
241
- sessionRoot: string;
242
- sessionId?: string;
243
- messages: readonly unknown[];
244
- obsConfig: ObservationPackConfig;
245
- limits?: { maxItems?: number; maxBytes?: number };
246
- pickMiddleExcerpt?: PickMiddleExcerpt;
247
- }): Promise<number> {
248
- const { sessionRoot, sessionId, messages, obsConfig } = opts;
249
- if (!obsConfig.enabled || !sessionRoot || !Array.isArray(messages)) return 0;
250
-
251
- const maxItems = opts.limits?.maxItems ?? MAX_BATCH_PACK_ITEMS;
252
- const maxBytes = opts.limits?.maxBytes ?? MAX_BATCH_PACK_BYTES;
253
-
254
- let packedCount = 0;
255
- let accumulatedBytes = 0;
256
-
257
- for (let i = 0; i < messages.length; i++) {
258
- if (packedCount >= maxItems) break;
259
- const msg = messages[i];
260
- if (!msg || msg.role !== 'toolResult' || (msg as { isError?: boolean }).isError) continue;
261
- const content = (msg as { content?: Array<{ type: string; text?: string }> }).content;
262
- if (!Array.isArray(content) || content.length === 0) continue;
263
- if (!content.every((b) => b && b.type === 'text' && typeof b.text === 'string')) continue;
264
-
265
- const toolName = (msg as { toolName?: string }).toolName ?? 'tool';
266
- const toolCallId = (msg as { toolCallId?: string }).toolCallId ?? `call_${i}`;
267
- const charLength = contentJoinedLength(content);
268
-
269
- const memoKey = memoKeyFor(sessionRoot, toolCallId, charLength);
270
- if (placeholderMemo.has(memoKey)) continue;
271
-
272
- const text = content.map((b) => b.text ?? '').join('\n');
273
- if (containsReducerReceipt(text)) continue;
274
-
275
- const textBytes = Buffer.byteLength(text, 'utf8');
276
- if (textBytes < obsConfig.thresholdBytes) continue;
277
-
278
- // Check soft maxBytes limit before packing
279
- if (accumulatedBytes + textBytes > maxBytes && packedCount > 0) break;
280
-
281
- const packed = await packOneMessage({
282
- sessionRoot,
283
- toolName,
284
- toolCallId,
285
- text,
286
- textBytes,
287
- charLength,
288
- obsConfig,
289
- log: { logEnabled: obsConfig.logEnabled, event: 'packed-batch', sessionId, extra: { source: 'compaction' } },
290
- pickMiddleExcerpt: opts.pickMiddleExcerpt,
291
- });
292
-
293
- if (packed) {
294
- packedCount++;
295
- accumulatedBytes += packed.originalBytes;
296
- }
297
- }
298
- return packedCount;
299
- }
300
-
301
- export interface ObservationPackDeps {
302
- pi: ExtensionAPI;
303
- getConfig?: (ctx: ExtensionContext) => EfficiencyConfig;
304
- getRuntimeManifest?: () => RuntimeRoleManifest | null;
305
- getRemainingHorizon?: () => number;
306
- pickMiddleExcerpt?: PickMiddleExcerpt;
307
- }
308
-
309
- export function registerObservationPack(deps: ObservationPackDeps): void {
310
- const { pi, getConfig, getRuntimeManifest, pickMiddleExcerpt } = deps;
311
-
312
- // 1. Register obs_recall tool
313
- pi.registerTool({
314
- name: RECALL_TOOL_NAME,
315
- label: 'Recall Observation',
316
- description:
317
- 'Recall a paged slice of a previously replaced large tool result by observation id and byte offset.',
318
- promptGuidelines: [
319
- 'Call obs_recall with id and offset to inspect specific parts of large tool outputs.',
320
- 'Check the returned next_offset and eof to page through long logs.',
321
- ],
322
- parameters: Type.Object({
323
- id: Type.String({ description: 'Observation ID from placeholder (e.g. obs_...)' }),
324
- offset: Type.Optional(
325
- Type.Integer({ minimum: 0, description: 'Byte offset to recall from (default 0)' }),
326
- ),
327
- }),
328
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
329
- void toolCallId;
330
- const id = params?.id;
331
- const offset = params?.offset ?? 0;
332
-
333
- if (!isObservationId(id)) {
334
- // A1: hard failures throw so pi flags isError for the model.
335
- return toolError(`invalid observation id format: "${id}"`);
336
- }
337
-
338
- const sessionDir = ctx?.sessionManager?.getSessionDir?.();
339
- const sessionId = ctx?.sessionManager?.getSessionId?.();
340
- const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
341
-
342
- if (!sessionRoot) {
343
- return toolError('session storage is unavailable for observation recall.');
344
- }
345
-
346
- const filePath = observationObjectPath(sessionRoot, id);
347
- const startMs = Date.now();
348
- const effConfig = getConfig ? getConfig(ctx) : resolveEfficiencyConfig();
349
- const recallMaxBytes = effConfig.observationPack.recallChunkBytes ?? RECALL_MAX_BYTES;
350
- try {
351
- const chunk = await readStoredObjectChunk(filePath, offset, {
352
- maxBytes: recallMaxBytes,
353
- maxLines: RECALL_MAX_LINES,
354
- });
355
-
356
- const header = [
357
- `[obs_recall id=${id} offset=${offset} next_offset=${chunk.nextOffset} eof=${chunk.eof}]`,
358
- `[chunk_bytes=${chunk.bytes} chunk_lines=${chunk.lines}; use next_offset to continue]`,
359
- ].join('\n');
360
-
361
- if (effConfig.observationPack.logEnabled) {
362
- const logPath = efficiencyLogPath(
363
- sessionRoot,
364
- 'observation',
365
- undefined,
366
- );
367
- void appendEfficiencyLog(logPath, {
368
- schema: 'pier-efficiency/1',
369
- mechanism: 'observationPack',
370
- event: 'recall',
371
- ts: new Date().toISOString(),
372
- sessionId: sessionId ?? 'unknown',
373
- obsId: id,
374
- offset,
375
- chunkBytes: chunk.bytes,
376
- lines: chunk.lines,
377
- nextOffset: chunk.nextOffset,
378
- eof: chunk.eof,
379
- durationMs: Date.now() - startMs,
380
- }).catch(() => {});
381
- }
382
-
383
- return {
384
- content: [{ type: 'text', text: `${header}\n${chunk.text}` }],
385
- details: {
386
- id,
387
- offset,
388
- bytes: chunk.bytes,
389
- lines: chunk.lines,
390
- nextOffset: chunk.nextOffset,
391
- eof: chunk.eof,
392
- },
393
- };
394
- } catch (err) {
395
- // Self-healing: if disk file is missing or corrupted, invalidate memo to repack if needed
396
- invalidateObservationMemo(id);
397
- return {
398
- content: [
399
- {
400
- type: 'text',
401
- text: `Error: failed to recall observation ${id}: ${err instanceof Error ? err.message : String(err)}`,
402
- },
403
- ],
404
- details: {},
405
- };
406
- }
407
- },
408
- });
409
-
410
- // 2. Intercept context event to project placeholders
411
- pi.on('context', async (event, ctx: ExtensionContext) => {
412
- if (!event || !Array.isArray(event.messages)) return;
413
-
414
- const effConfig = getConfig ? getConfig(ctx) : resolveEfficiencyConfig();
415
- const obsConfig: ObservationPackConfig = effConfig.observationPack;
416
- if (!obsConfig.enabled) return;
417
-
418
- // Role visibility gate check: if current role denies obs_recall, skip packing completely!
419
- const manifest = getRuntimeManifest ? getRuntimeManifest() : null;
420
- if (manifest) {
421
- const gate = planToolGate(RECALL_TOOL_NAME, manifest);
422
- if (gate.kind === 'deny') {
423
- return; // Skip packing: agent would not be permitted to call obs_recall
424
- }
425
- }
426
-
427
- const sessionDir = ctx?.sessionManager?.getSessionDir?.();
428
- const sessionId = ctx?.sessionManager?.getSessionId?.();
429
- const sessionRoot = resolveSessionRoot(sessionDir, sessionId);
430
- if (!sessionRoot) return; // Fail-open if session directory is absent
431
-
432
- const projected = [...event.messages];
433
- const len = projected.length;
434
-
435
- // Calculate how many assistant responses followed each message
436
- const priorAssistantCounts = new Array<number>(len);
437
- let assistantCount = 0;
438
- for (let i = len - 1; i >= 0; i--) {
439
- priorAssistantCounts[i] = assistantCount;
440
- if (projected[i]?.role === 'assistant') {
441
- assistantCount++;
442
- }
443
- }
444
-
445
- for (let i = 0; i < len; i++) {
446
- const msg = projected[i];
447
- if (!msg || msg.role !== 'toolResult' || (msg as { isError?: boolean }).isError) continue;
448
- const content = (msg as { content?: Array<{ type: string; text?: string }> }).content;
449
- if (!Array.isArray(content) || content.length === 0) continue;
450
- if (!content.every((b) => b && b.type === 'text' && typeof b.text === 'string')) continue;
451
-
452
- const toolName = (msg as { toolName?: string }).toolName ?? 'tool';
453
- const toolCallId = (msg as { toolCallId?: string }).toolCallId ?? `call_${i}`;
454
- const charLength = contentJoinedLength(content);
455
-
456
- // N1' Fast Path: check memo BEFORE text join, containsReducerReceipt, and byteLength!
457
- // Once packed, observation remains sticky until memo eviction.
458
- const memoKey = memoKeyFor(sessionRoot, toolCallId, charLength);
459
- const memoized = placeholderMemo.get(memoKey);
460
- if (memoized) {
461
- // Refresh LRU position
462
- placeholderMemo.delete(memoKey);
463
- placeholderMemo.set(memoKey, memoized);
464
- projected[i] = {
465
- ...msg,
466
- content: [{ type: 'text', text: memoized.placeholder }],
467
- };
468
- continue;
469
- }
470
-
471
- const sendCount = priorAssistantCounts[i] ?? 0;
472
- // Active use window: do not touch and do not store to disk prematurely!
473
- if (sendCount < obsConfig.fullSends) {
474
- continue;
475
- }
476
-
477
- const text = content.map((b) => b.text ?? '').join('\n');
478
- if (containsReducerReceipt(text)) continue;
479
-
480
- const textBytes = Buffer.byteLength(text, 'utf8');
481
- if (textBytes < obsConfig.thresholdBytes) continue;
482
-
483
- // Calculate tail tokens after this message
484
- let tailTokensAfter = 0;
485
- for (let j = i + 1; j < len; j++) {
486
- const afterMsg = projected[j];
487
- if (afterMsg && Array.isArray((afterMsg as { content?: unknown[] }).content)) {
488
- for (const b of (afterMsg as { content: Array<{ type: string; text?: string }> }).content) {
489
- if (b && b.type === 'text' && typeof b.text === 'string') {
490
- tailTokensAfter += estimateTokens(b.text);
491
- }
492
- }
493
- }
494
- }
495
-
496
- const contentHash = sha256Hex(text);
497
- const obsId = deriveObservationId(toolName, toolCallId, contentHash);
498
- const originalTokens = estimateTokens(text);
499
- const lines = countLines(text);
500
- const placeholder = formatObservationPlaceholder({
501
- id: obsId,
502
- toolName,
503
- bytes: textBytes,
504
- lines,
505
- tokens: originalTokens,
506
- text,
507
- fullSends: obsConfig.fullSends,
508
- excerptBudget: obsConfig.excerptBytes,
509
- });
510
- const placeholderTokens = estimateTokens(placeholder);
511
- const removedTokens = Math.max(0, originalTokens - placeholderTokens);
512
-
513
- const remainingHorizon = deps.getRemainingHorizon ? deps.getRemainingHorizon() : 4;
514
- // Same resolution as OCC (provider family / token-account fallback); the old
515
- // raw pass-through of 'auto' let shouldPackForCache hardcode 12.5 internally.
516
- const cacheRatio = resolveCacheRatioFromCost(
517
- effConfig.onlineContextCompact.cacheWriteReadRatio,
518
- ctx.model?.cost,
519
- { provider: ctx.model?.provider, modelId: ctx.model?.id },
520
- );
521
- const canPack = shouldPackForCache({
522
- removedTokens,
523
- tailTokensAfter,
524
- expectedRemainingRequests: remainingHorizon,
525
- cacheWriteReadRatio: cacheRatio,
526
- });
527
- if (canPack) {
528
- const packed = await packOneMessage({
529
- sessionRoot,
530
- toolName,
531
- toolCallId,
532
- text,
533
- textBytes,
534
- charLength,
535
- obsConfig,
536
- log: {
537
- logEnabled: obsConfig.logEnabled,
538
- event: 'packed',
539
- sessionId,
540
- extra: { sendCount, tailTokensAfter },
541
- },
542
- pickMiddleExcerpt,
543
- });
544
- if (!packed) continue;
545
-
546
- projected[i] = {
547
- ...msg,
548
- content: [{ type: 'text', text: packed.placeholder }],
549
- };
550
- }
551
- }
552
-
553
- return { messages: projected };
554
- });
555
- }