pi-pier 0.3.0 → 0.4.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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  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 +10 -13
  9. package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,35 +1,33 @@
1
1
  /**
2
- * Controlled mirror of workbench heat-layout for spawn-time pane.split ratio.
2
+ * Controlled mirror of workbench heat-layout for spawn-time pane.split ratio — do NOT merge with
3
+ * pier-workbench/src/heat-layout.ts (npm pi-pier has no workbench, and the workbench checkout has no
4
+ * pi-pier). Only planSpawnSplitRatio / simulateSplit / SPAWN_PLACEHOLDER_ID / fromShapeTree / LayoutNode
5
+ * are the interface; the weights stay in lockstep with packages/pier-workbench/test/heat-grid.test.ts.
3
6
  *
4
- * Cannot import pier-workbench (npm pi-pier has no workbench; workbench user-mode
5
- * checkout has no pi-pier). Canonical tree shape matches workbench LayoutNode.
6
- * Lockstep: packages/pier-workbench/test/heat-grid.test.ts.
7
- *
8
- * herdr v0.9.1 src/layout.rs split_at: first = original, second = new, ratio = first share.
7
+ * herdr src/layout.rs split_at: first = original, second = new, ratio = first share.
9
8
  */
10
9
  import { type ShapeNode } from './grid-shape.ts';
11
10
 
12
11
  export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
13
- export const RATIO_FLOOR = 0.10;
14
- export const FOCUS_SHARE = 0.72;
15
- export const FOCUS_SHARE_BLOCKED = 0.60;
16
- export const MAX_AUTO_LAYOUT_PANES = 10;
17
- export const BLOCKED_WEIGHT = 3;
18
- export const ASK_WEIGHT = 2.5;
19
- export const WORKING_WEIGHT = 1.4;
20
- export const IDLE_WEIGHT = 1;
21
- export const SLIM_THRESHOLD = 4;
22
- export const SLIM_FACTOR = 0.6;
12
+ const RATIO_FLOOR = 0.10;
13
+ const FOCUS_SHARE = 0.72;
14
+ const FOCUS_SHARE_BLOCKED = 0.60;
15
+ const MAX_AUTO_LAYOUT_PANES = 10;
16
+ const BLOCKED_WEIGHT = 3;
17
+ const ASK_WEIGHT = 2.5;
18
+ const WORKING_WEIGHT = 1.4;
19
+ const IDLE_WEIGHT = 1;
20
+ const SLIM_THRESHOLD = 4;
21
+ const SLIM_FACTOR = 0.6;
23
22
 
24
23
  /** Same shape as pier-workbench heat-layout LayoutNode. */
25
24
  export type LayoutNode =
26
25
  | { type: 'pane'; pane_id: string }
27
26
  | { type: 'split'; direction: 'right' | 'down'; ratio: number; first: LayoutNode; second: LayoutNode };
28
27
 
29
- export type AgentStatusMap = Record<string, string>;
30
- export type AskFlagMap = Record<string, boolean>;
28
+ type AgentStatusMap = Record<string, string>;
29
+ type AskFlagMap = Record<string, boolean>;
31
30
 
32
- /** parseShapeTree (paneId) → LayoutNode (pane_id). */
33
31
  export function fromShapeTree(node: ShapeNode): LayoutNode {
34
32
  if (node.type === 'pane') return { type: 'pane', pane_id: node.paneId };
35
33
  const direction = node.direction === 'down' || node.direction === 'vertical' ? 'down' : 'right';
@@ -42,15 +40,15 @@ export function fromShapeTree(node: ShapeNode): LayoutNode {
42
40
  };
43
41
  }
44
42
 
45
- export function countPanes(node: LayoutNode): number {
43
+ function countPanes(node: LayoutNode): number {
46
44
  return node.type === 'pane' ? 1 : countPanes(node.first) + countPanes(node.second);
47
45
  }
48
46
 
49
- export function flattenPanes(node: LayoutNode): string[] {
47
+ function flattenPanes(node: LayoutNode): string[] {
50
48
  return node.type === 'pane' ? [node.pane_id] : [...flattenPanes(node.first), ...flattenPanes(node.second)];
51
49
  }
52
50
 
53
- export function containsPane(node: LayoutNode, paneId: string): boolean {
51
+ function containsPane(node: LayoutNode, paneId: string): boolean {
54
52
  if (node.type === 'pane') return node.pane_id === paneId;
55
53
  return containsPane(node.first, paneId) || containsPane(node.second, paneId);
56
54
  }
@@ -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
+ }