pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -0,0 +1,158 @@
1
+ /**
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.
6
+ *
7
+ * herdr src/layout.rs split_at: first = original, second = new, ratio = first share.
8
+ */
9
+ import { type ShapeNode } from './grid-shape.ts';
10
+
11
+ export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
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;
22
+
23
+ /** Same shape as pier-workbench heat-layout LayoutNode. */
24
+ export type LayoutNode =
25
+ | { type: 'pane'; pane_id: string }
26
+ | { type: 'split'; direction: 'right' | 'down'; ratio: number; first: LayoutNode; second: LayoutNode };
27
+
28
+ type AgentStatusMap = Record<string, string>;
29
+ type AskFlagMap = Record<string, boolean>;
30
+
31
+ export function fromShapeTree(node: ShapeNode): LayoutNode {
32
+ if (node.type === 'pane') return { type: 'pane', pane_id: node.paneId };
33
+ const direction = node.direction === 'down' || node.direction === 'vertical' ? 'down' : 'right';
34
+ return {
35
+ type: 'split',
36
+ direction,
37
+ ratio: node.ratio,
38
+ first: fromShapeTree(node.first),
39
+ second: fromShapeTree(node.second),
40
+ };
41
+ }
42
+
43
+ function countPanes(node: LayoutNode): number {
44
+ return node.type === 'pane' ? 1 : countPanes(node.first) + countPanes(node.second);
45
+ }
46
+
47
+ function flattenPanes(node: LayoutNode): string[] {
48
+ return node.type === 'pane' ? [node.pane_id] : [...flattenPanes(node.first), ...flattenPanes(node.second)];
49
+ }
50
+
51
+ function containsPane(node: LayoutNode, paneId: string): boolean {
52
+ if (node.type === 'pane') return node.pane_id === paneId;
53
+ return containsPane(node.first, paneId) || containsPane(node.second, paneId);
54
+ }
55
+
56
+ function clampRatio(r: number): number {
57
+ return Math.min(1 - RATIO_FLOOR, Math.max(RATIO_FLOOR, r));
58
+ }
59
+
60
+ function tierWeight(status: string | undefined, isAsk = false, slim = false): number {
61
+ if (status === 'blocked') return isAsk ? ASK_WEIGHT : BLOCKED_WEIGHT;
62
+ if (status === 'working') return slim ? WORKING_WEIGHT * SLIM_FACTOR : WORKING_WEIGHT;
63
+ return slim ? IDLE_WEIGHT * SLIM_FACTOR : IDLE_WEIGHT;
64
+ }
65
+
66
+ type SplitNode = Extract<LayoutNode, { type: 'split' }>;
67
+ interface PathStep { node: SplitNode; side: 'first' | 'second' }
68
+
69
+ function findPath(node: LayoutNode, targetId: string): PathStep[] | null {
70
+ if (node.type === 'pane') return node.pane_id === targetId ? [] : null;
71
+ if (containsPane(node.first, targetId)) {
72
+ return [{ node, side: 'first' }, ...(findPath(node.first, targetId) ?? [])];
73
+ }
74
+ if (containsPane(node.second, targetId)) {
75
+ return [{ node, side: 'second' }, ...(findPath(node.second, targetId) ?? [])];
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function weightOf(node: LayoutNode, statuses: AgentStatusMap, askFlags: AskFlagMap, slim: boolean): number {
81
+ return flattenPanes(node).reduce((sum, id) => sum + tierWeight(statuses[id], askFlags[id] === true, slim), 0);
82
+ }
83
+
84
+ /** Replace target leaf with split(first=old, second=new). Matches herdr split_at. */
85
+ export function simulateSplit(
86
+ root: LayoutNode,
87
+ targetPaneId: string,
88
+ newPaneId: string,
89
+ direction: 'right' | 'down',
90
+ ): LayoutNode | null {
91
+ const rec = (n: LayoutNode): LayoutNode | null => {
92
+ if (n.type === 'pane') {
93
+ if (n.pane_id !== targetPaneId) return null;
94
+ return {
95
+ type: 'split',
96
+ direction,
97
+ ratio: 0.5,
98
+ first: n,
99
+ second: { type: 'pane', pane_id: newPaneId },
100
+ };
101
+ }
102
+ const first = rec(n.first);
103
+ if (first) return { ...n, first };
104
+ const second = rec(n.second);
105
+ if (second) return { ...n, second };
106
+ return null;
107
+ };
108
+ return rec(root);
109
+ }
110
+
111
+ /**
112
+ * First-child ratio for pane.split so the new layer matches post-spawn heat.
113
+ * On-path: T^(1/depth) if focus is first, else 1-T. Off-path: weighted first share.
114
+ * Null → omit ratio (herdr 0.5). Caller must skip when the tab is zoomed.
115
+ */
116
+ export function planSpawnSplitRatio(opts: {
117
+ root: LayoutNode;
118
+ targetPaneId: string;
119
+ focusPaneId: string;
120
+ direction: 'right' | 'down';
121
+ statuses?: AgentStatusMap;
122
+ askFlags?: AskFlagMap;
123
+ newPaneId?: string;
124
+ }): number | null {
125
+ const newId = opts.newPaneId ?? SPAWN_PLACEHOLDER_ID;
126
+ const simulated = simulateSplit(opts.root, opts.targetPaneId, newId, opts.direction);
127
+ if (!simulated) return null;
128
+ const paneCount = countPanes(simulated);
129
+ if (paneCount <= 1 || paneCount > MAX_AUTO_LAYOUT_PANES) return null;
130
+ if (!containsPane(simulated, opts.focusPaneId) || !containsPane(simulated, newId)) return null;
131
+
132
+ const statuses = opts.statuses ?? {};
133
+ const askFlags = opts.askFlags ?? {};
134
+ const slim = paneCount - 1 >= SLIM_THRESHOLD;
135
+ const focusSteps = findPath(simulated, opts.focusPaneId);
136
+ const newSteps = findPath(simulated, newId);
137
+ if (!focusSteps || !newSteps || newSteps.length === 0) return null;
138
+
139
+ const newSplit = newSteps[newSteps.length - 1]!.node;
140
+ const onFocusPath = focusSteps.some((s) => s.node === newSplit);
141
+
142
+ if (onFocusPath) {
143
+ const hasBlocked = focusSteps.some((step) => {
144
+ const sibling = step.side === 'first' ? step.node.second : step.node.first;
145
+ return flattenPanes(sibling).some((id) => statuses[id] === 'blocked');
146
+ });
147
+ const target = hasBlocked ? FOCUS_SHARE_BLOCKED : FOCUS_SHARE;
148
+ const r = clampRatio(target ** (1 / focusSteps.length));
149
+ const at = focusSteps.find((s) => s.node === newSplit);
150
+ if (!at) return null;
151
+ return at.side === 'first' ? r : 1 - r;
152
+ }
153
+
154
+ const wFirst = weightOf(newSplit.first, statuses, askFlags, slim);
155
+ const wSecond = weightOf(newSplit.second, statuses, askFlags, slim);
156
+ if (wFirst + wSecond <= 0) return null;
157
+ return clampRatio(wFirst / (wFirst + wSecond));
158
+ }
@@ -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
+ }