@dyyz1993/pi-coding-agent 0.74.47 → 0.74.48

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.
@@ -27,6 +27,8 @@ function loadConfig(): CompactionManagerConfig {
27
27
  return DEFAULT_CONFIG;
28
28
  }
29
29
 
30
+ let compactMetrics = { foldCount: 0, memoryCompactCount: 0, forceCompactCount: 0, rateLimitHits: 0, serverErrors: 0 };
31
+
30
32
  export default function (pi: ExtensionAPI) {
31
33
  const config = loadConfig();
32
34
 
@@ -70,11 +72,17 @@ export default function (pi: ExtensionAPI) {
70
72
  pi.foldEntry(entry.id, summary, tokens);
71
73
  }
72
74
 
75
+ compactMetrics.foldCount++;
73
76
  ctx.ui.notify(`Context fold: folded ${foldable.length} old message(s)`, "info");
77
+ pi.appendEntry("compaction_fold", {
78
+ count: foldable.length,
79
+ totalFolds: compactMetrics.foldCount,
80
+ timestamp: Date.now(),
81
+ });
74
82
  });
75
83
  }
76
84
 
77
- if (config.sessionMemory.enabled) {
85
+ if (config.sessionMemory.enabled) {
78
86
  pi.on("session_before_compact", async (event, ctx) => {
79
87
  const { preparation, signal } = event;
80
88
 
@@ -84,10 +92,16 @@ export default function (pi: ExtensionAPI) {
84
92
  const result = buildMemorySummary(memoryFiles, preparation, config.sessionMemory.minContentLength);
85
93
  if (!result) return;
86
94
 
95
+ compactMetrics.memoryCompactCount++;
87
96
  ctx.ui.notify(
88
97
  `Session Memory Compact: using ${memoryFiles.size} memory files instead of LLM summary`,
89
98
  "info",
90
99
  );
100
+ pi.appendEntry("compaction_session_memory", {
101
+ memoryFiles: memoryFiles.size,
102
+ totalMemory: compactMetrics.memoryCompactCount,
103
+ timestamp: Date.now(),
104
+ });
91
105
 
92
106
  return { compaction: result };
93
107
  });
@@ -98,9 +112,28 @@ export default function (pi: ExtensionAPI) {
98
112
 
99
113
  pi.on("after_provider_response", (event, ctx) => {
100
114
  if (event.status === 429) {
115
+ compactMetrics.rateLimitHits++;
101
116
  ctx.ui.notify("Rate limited — API is throttling requests", "warning");
117
+ pi.sendUserMessage(
118
+ "[System: API Rate Limited] Requests are being throttled. The session will retry automatically. Consider reducing tool call frequency or waiting.",
119
+ { deliverAs: "followUp" },
120
+ );
121
+ pi.appendEntry("compaction_rate_limit", {
122
+ total: compactMetrics.rateLimitHits,
123
+ timestamp: Date.now(),
124
+ });
102
125
  } else if (event.status >= 500) {
126
+ compactMetrics.serverErrors++;
103
127
  ctx.ui.notify(`API server error (${event.status}) — will retry automatically`, "warning");
128
+ pi.sendUserMessage(
129
+ `[System: API Error] Server error (${event.status}) encountered. The session will retry automatically.`,
130
+ { deliverAs: "followUp" },
131
+ );
132
+ pi.appendEntry("compaction_server_error", {
133
+ status: event.status,
134
+ total: compactMetrics.serverErrors,
135
+ timestamp: Date.now(),
136
+ });
104
137
  }
105
138
  });
106
139
 
@@ -111,18 +144,35 @@ export default function (pi: ExtensionAPI) {
111
144
  const { tokens, contextWindow, percent } = usage;
112
145
 
113
146
  if (shouldForceCompact(tokens, contextWindow, config.reactive.forceCompactPercent) && !warnedThisTurn) {
114
- ctx.ui.notify(
115
- `Context critical: ${percent!.toFixed(0)}% (${tokens!.toLocaleString()} / ${contextWindow.toLocaleString()} tokens). Consider /compact-force.`,
116
- "warning",
147
+ const msg = `Context critical: ${percent!.toFixed(0)}% (${tokens!.toLocaleString()} / ${contextWindow.toLocaleString()} tokens). Automatic compaction imminent.`;
148
+ ctx.ui.notify(msg, "warning");
149
+ pi.sendUserMessage(
150
+ `[System: Context Warning — Action Required]\n`
151
+ + `Context window is at ${percent!.toFixed(0)}% — critical. Automatic compaction will occur soon.\n\n`
152
+ + `Compaction is lossy: after compression, the conversation context may lose detail, making current results unreliable.\n\n`
153
+ + `Recommended actions:\n`
154
+ + `1. Return your current findings and conclusions to the parent session now (before compaction).\n`
155
+ + `2. If the task is complex, break it down: return your direction/framework to the parent,\n`
156
+ + ` so the parent can re-dispatch more targeted sub-tasks with fresh context.\n`
157
+ + `3. Use available communication tools to send results back.`,
158
+ { deliverAs: "followUp" },
117
159
  );
118
160
  warnedThisTurn = true;
119
161
  return;
120
162
  }
121
163
 
122
164
  if (shouldWarn(tokens, contextWindow, config.reactive.warnPercent) && !warnedThisTurn) {
123
- ctx.ui.notify(
124
- `Context high: ${percent!.toFixed(0)}% (${tokens!.toLocaleString()} / ${contextWindow.toLocaleString()} tokens)`,
125
- "info",
165
+ const msg = `Context high: ${percent!.toFixed(0)}% (${tokens!.toLocaleString()} / ${contextWindow.toLocaleString()} tokens)`;
166
+ ctx.ui.notify(msg, "info");
167
+ pi.sendUserMessage(
168
+ `[System: Context Warning — Proactive]\n`
169
+ + `Context window at ${percent!.toFixed(0)}% — approaching the compaction threshold.\n\n`
170
+ + `Before automatic compaction reduces context fidelity:\n`
171
+ + `- Consider completing the current task and returning results now.\n`
172
+ + `- If the task is still large, return your current findings and direction,\n`
173
+ + ` so the parent can re-dispatch with a more focused scope.\n`
174
+ + `- Lossy compaction will make current details unreliable after compression.`,
175
+ { deliverAs: "followUp" },
126
176
  );
127
177
  warnedThisTurn = true;
128
178
  }
@@ -139,10 +189,21 @@ export default function (pi: ExtensionAPI) {
139
189
  ctx.compact({
140
190
  customInstructions: instructions,
141
191
  onComplete: (result) => {
192
+ compactMetrics.forceCompactCount++;
142
193
  ctx.ui.notify(`Compaction done: ${result.tokensBefore.toLocaleString()} tokens compressed`, "info");
194
+ pi.appendEntry("compaction_force", {
195
+ tokensBefore: result.tokensBefore,
196
+ total: compactMetrics.forceCompactCount,
197
+ timestamp: Date.now(),
198
+ });
143
199
  },
144
200
  onError: (error) => {
145
201
  ctx.ui.notify(`Compaction failed: ${error.message}`, "error");
202
+ pi.appendEntry("compaction_failed", {
203
+ error: error.message,
204
+ total: compactMetrics.forceCompactCount,
205
+ timestamp: Date.now(),
206
+ });
146
207
  },
147
208
  });
148
209
  },
@@ -161,12 +161,22 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
161
161
  const result = await serverProxy.delegate(params.task, projectPath);
162
162
 
163
163
  if (!result.sessionId) {
164
+ console.debug("[coordinator] delegate failed: no sessionId returned");
165
+ pi.appendEntry("coordinator_delegate_failed", { task: params.task, projectPath });
164
166
  return {
165
167
  content: [{ type: "text" as const, text: `Failed to delegate task: no sessionId returned.` }],
166
168
  details: { error: "no sessionId" },
167
169
  };
168
170
  }
169
171
 
172
+ pi.appendEntry("coordinator_delegate", {
173
+ sessionId: result.sessionId,
174
+ status: result.status,
175
+ task: params.task,
176
+ title: params.title,
177
+ projectPath,
178
+ dispatchedBy: sid,
179
+ });
170
180
  return {
171
181
  content: [{ type: "text" as const, text: `Delegated task to session ${result.sessionId} (status: ${result.status}, cwd: ${projectPath}). Use session_delegate_send to communicate.` }],
172
182
  details: { ...result, dispatchedBy: sid, projectPath },
@@ -190,12 +200,14 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
190
200
  const result = await serverProxy.delegate_send(sid, params.targetSessionId, params.message);
191
201
 
192
202
  if (!result.delivered) {
203
+ pi.appendEntry("coordinator_send_failed", { fromSessionId: sid, toSessionId: params.targetSessionId });
193
204
  return {
194
205
  content: [{ type: "text" as const, text: `Could not deliver message to ${params.targetSessionId}: session not found (the session file may have been deleted from disk)` }],
195
206
  details: { delivered: false, targetSessionId: params.targetSessionId },
196
207
  };
197
208
  }
198
209
 
210
+ pi.appendEntry("coordinator_send", { fromSessionId: sid, toSessionId: params.targetSessionId, status: result.targetStatus });
199
211
  return {
200
212
  content: [{ type: "text" as const, text: `Message delivered to ${params.targetSessionId} (status: ${result.targetStatus})` }],
201
213
  details: result,
@@ -253,6 +265,15 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
253
265
  const sid = currentSessionId || ctx.sessionManager.getSessionId();
254
266
  const projectPath = params.projectPath || ctx.cwd;
255
267
  const result = await serverProxy.delegate_fork(params.sessionId, params.task, params.title, projectPath);
268
+ pi.appendEntry("coordinator_fork", {
269
+ sessionId: result.sessionId,
270
+ forkedFrom: params.sessionId,
271
+ status: result.status,
272
+ task: params.task,
273
+ title: params.title,
274
+ projectPath,
275
+ dispatchedBy: sid,
276
+ });
256
277
  return {
257
278
  content: [{ type: "text" as const, text: `Forked session ${params.sessionId} → ${result.sessionId} (status: ${result.status}, cwd: ${projectPath}). Task: ${params.task}` }],
258
279
  details: { ...result, forkedFrom: params.sessionId, dispatchedBy: sid, projectPath },
@@ -260,6 +281,21 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
260
281
  },
261
282
  });
262
283
 
284
+ pi.registerTool({
285
+ name: "session_delegate_stop",
286
+ label: "Session Delegate Stop",
287
+ description: "Stop a delegated task session.",
288
+ parameters: DelegateStopParams,
289
+ async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
290
+ const ok = await serverProxy.delegate_stop(params.sessionId);
291
+ pi.appendEntry("coordinator_stop", { sessionId: params.sessionId, ok });
292
+ return {
293
+ content: [{ type: "text" as const, text: ok ? `Session ${params.sessionId} stopped.` : `Session ${params.sessionId} not found or already stopped.` }],
294
+ details: { ok },
295
+ };
296
+ },
297
+ });
298
+
263
299
  pi.registerTool({
264
300
  name: "session_delegate_remove",
265
301
  label: "Session Delegate Remove",
@@ -271,6 +307,7 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
271
307
  parameters: DelegateStatusParams,
272
308
  async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
273
309
  const ok = await serverProxy.delegate_remove(params.sessionId);
310
+ pi.appendEntry("coordinator_remove", { sessionId: params.sessionId, ok });
274
311
  return {
275
312
  content: [{ type: "text" as const, text: ok ? `Task ${params.sessionId} removed.` : `Task ${params.sessionId} not found.` }],
276
313
  details: { ok },
@@ -288,6 +325,7 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
288
325
  parameters: Type.Object({}),
289
326
  async execute(toolCallId, _params, _signal, _onUpdate, _ctx) {
290
327
  const removed = await serverProxy.delegate_clear_stopped();
328
+ pi.appendEntry("coordinator_clear_stopped", { removed });
291
329
  return {
292
330
  content: [{ type: "text" as const, text: `Cleared ${removed} stopped/completed task(s).` }],
293
331
  details: { removed },
@@ -307,6 +345,7 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
307
345
  const isCompletion = lowerMsg.includes("[completed]") || lowerMsg.includes("[done]") || lowerMsg.includes("task completed");
308
346
  if (isCompletion) {
309
347
  store.update(d.fromSessionId, { status: "completed", completedAt: Date.now(), result: d.message });
348
+ pi.appendEntry("coordinator_task_completed", { sessionId: d.fromSessionId, task: task.title, result: d.message.slice(0, 200) });
310
349
  } else if (task.status !== "completed") {
311
350
  store.update(d.fromSessionId, { status: "streaming" });
312
351
  }
@@ -193,6 +193,9 @@ export default function hooksEngine(pi: ExtensionAPI): void {
193
193
 
194
194
  if (promptResults.length > 0) {
195
195
  console.log("[hook] Prompts to inject:", promptResults);
196
+ for (const prompt of promptResults) {
197
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
198
+ }
196
199
  }
197
200
 
198
201
  return undefined;
@@ -0,0 +1,302 @@
1
+ import { extname } from "node:path";
2
+
3
+ export interface SmartFileTrackerOptions {
4
+ maxOpenFiles?: number;
5
+ now?: () => number;
6
+ priorityMap?: Record<string, number>;
7
+ excludedExtensions?: Set<string>;
8
+ }
9
+
10
+ export interface MemoryUsage {
11
+ heapUsed: number;
12
+ heapTotal: number;
13
+ }
14
+
15
+ export interface TrackerStatistics {
16
+ totalOpens: number;
17
+ totalEvictions: number;
18
+ evictionReasons: Array<"window_full" | "memory_pressure" | "excluded_type">;
19
+ accessCounts: Map<string, number>;
20
+ openFileCount: number;
21
+ }
22
+
23
+ interface TrackedFile {
24
+ filePath: string;
25
+ lastAccess: number;
26
+ modifiedTime: number;
27
+ accessCount: number;
28
+ priority: number;
29
+ }
30
+
31
+ // Default priority map for common file types (higher = more important)
32
+ const DEFAULT_PRIORITY_MAP: Record<string, number> = {
33
+ // TypeScript/JavaScript (highest)
34
+ ".ts": 100,
35
+ ".tsx": 100,
36
+ ".js": 90,
37
+ ".jsx": 90,
38
+ ".mjs": 90,
39
+ ".cjs": 90,
40
+
41
+ // Framework files
42
+ ".vue": 95,
43
+ ".svelte": 95,
44
+
45
+ // Other languages
46
+ ".py": 80,
47
+ ".rs": 80,
48
+ ".go": 80,
49
+ ".java": 75,
50
+ ".kt": 75,
51
+ ".c": 70,
52
+ ".cpp": 70,
53
+ ".h": 70,
54
+ ".hpp": 70,
55
+ ".cs": 70,
56
+
57
+ // Config files
58
+ ".json": 30,
59
+ ".yaml": 30,
60
+ ".yml": 30,
61
+ ".toml": 30,
62
+
63
+ // Documentation
64
+ ".md": 20,
65
+
66
+ // Text files
67
+ ".txt": 10,
68
+ };
69
+
70
+ // Default excluded file types (never tracked)
71
+ const DEFAULT_EXCLUDED_EXTENSIONS: Set<string> = new Set([
72
+ ".log",
73
+ ".bak",
74
+ ".tmp",
75
+ ".temp",
76
+ ".swp",
77
+ ".cache",
78
+ ".DS_Store",
79
+ ".map",
80
+ ".lock",
81
+ ".pid",
82
+ // Special files without extension
83
+ ".gitignore",
84
+ ".gitattributes",
85
+ ".gitmodules",
86
+ ".editorconfig",
87
+ ".eslintrc",
88
+ ".prettierrc",
89
+ ]);
90
+
91
+ // Memory thresholds (in bytes)
92
+ const HIGH_MEMORY_THRESHOLD = 3_000_000_000; // 3GB
93
+ const CRITICAL_MEMORY_THRESHOLD = 3_500_000_000; // 3.5GB
94
+ const BASE_MAX_FILES = 30;
95
+ const HIGH_MEMORY_MAX_FILES = 10;
96
+ const CRITICAL_MEMORY_MAX_FILES = 5;
97
+
98
+ export interface SmartFileTracker {
99
+ open(filePath: string, onClose: (file: string) => void, mtime?: number): void;
100
+ getOpenFiles(): string[];
101
+ closeAll(onClose: (file: string) => void): void;
102
+ updateMemoryUsage(usage: MemoryUsage): void;
103
+ getStatistics(): TrackerStatistics;
104
+ }
105
+
106
+ export function createSmartFileTracker(options: SmartFileTrackerOptions = {}): SmartFileTracker {
107
+ const baseMaxOpenFiles = options.maxOpenFiles ?? BASE_MAX_FILES;
108
+ const now = options.now ?? (() => Date.now());
109
+ const priorityMap = options.priorityMap ?? DEFAULT_PRIORITY_MAP;
110
+ const excludedExtensions = options.excludedExtensions ?? DEFAULT_EXCLUDED_EXTENSIONS;
111
+
112
+ const files = new Map<string, TrackedFile>();
113
+ const stats: TrackerStatistics = {
114
+ totalOpens: 0,
115
+ totalEvictions: 0,
116
+ evictionReasons: [],
117
+ accessCounts: new Map(),
118
+ openFileCount: 0,
119
+ };
120
+
121
+ let memoryUsage: MemoryUsage = {
122
+ heapUsed: 0,
123
+ heapTotal: 0,
124
+ };
125
+
126
+ // Calculate current max files based on memory pressure
127
+ function getCurrentMaxFiles(): number {
128
+ if (!memoryUsage.heapUsed || !memoryUsage.heapTotal) {
129
+ return baseMaxOpenFiles;
130
+ }
131
+
132
+ const memoryRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
133
+
134
+ if (memoryRatio > CRITICAL_MEMORY_THRESHOLD / memoryUsage.heapTotal) {
135
+ return Math.min(CRITICAL_MEMORY_MAX_FILES, baseMaxOpenFiles);
136
+ }
137
+
138
+ if (memoryRatio > HIGH_MEMORY_THRESHOLD / memoryUsage.heapTotal) {
139
+ return Math.min(HIGH_MEMORY_MAX_FILES, baseMaxOpenFiles);
140
+ }
141
+
142
+ return baseMaxOpenFiles;
143
+ }
144
+
145
+ // Get file priority based on extension
146
+ function getFilePriority(filePath: string): number {
147
+ const ext = extname(filePath).toLowerCase();
148
+ return priorityMap[ext] ?? 50; // Default priority for unknown types
149
+ }
150
+
151
+ // Check if file should be excluded
152
+ function isExcluded(filePath: string): boolean {
153
+ const ext = extname(filePath).toLowerCase();
154
+ if (excludedExtensions.has(ext)) {
155
+ return true;
156
+ }
157
+
158
+ // Check excluded file names (files without extension like .gitignore)
159
+ const basename = filePath.split("/").pop() ?? filePath;
160
+ if (excludedExtensions.has(basename)) {
161
+ return true;
162
+ }
163
+
164
+ return false;
165
+ }
166
+
167
+ // Sort files by priority (higher first), then by modified time (newer first), then by last access
168
+ function getSortedFiles(): TrackedFile[] {
169
+ return Array.from(files.values()).sort((a, b) => {
170
+ // Higher priority first
171
+ if (b.priority !== a.priority) {
172
+ return b.priority - a.priority;
173
+ }
174
+ // Newer modified time first
175
+ if (b.modifiedTime !== a.modifiedTime) {
176
+ return b.modifiedTime - a.modifiedTime;
177
+ }
178
+ // More recently accessed first
179
+ if (b.lastAccess !== a.lastAccess) {
180
+ return b.lastAccess - a.lastAccess;
181
+ }
182
+ // Higher access count first
183
+ return b.accessCount - a.accessCount;
184
+ });
185
+ }
186
+
187
+ // Evict lowest priority file(s) to fit new file
188
+ function evictIfNeeded(onClose: (file: string) => void): void {
189
+ const currentMax = getCurrentMaxFiles();
190
+ // Evict if we're at or over the limit (before adding new file)
191
+ while (files.size >= currentMax) {
192
+ const sorted = getSortedFiles();
193
+ const lowest = sorted[sorted.length - 1]; // Last element has lowest priority
194
+
195
+ if (!lowest) {
196
+ break;
197
+ }
198
+
199
+ files.delete(lowest.filePath);
200
+ onClose(lowest.filePath);
201
+
202
+ stats.totalEvictions++;
203
+ stats.evictionReasons.push("window_full");
204
+ stats.openFileCount = files.size;
205
+
206
+ // If memory is high, evict multiple files
207
+ const memoryRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
208
+ if (memoryRatio > HIGH_MEMORY_THRESHOLD / memoryUsage.heapTotal && files.size > currentMax / 2) {
209
+ continue; // Keep evicting
210
+ }
211
+
212
+ break;
213
+ }
214
+ }
215
+
216
+ // Evict files to fit within memory constraints
217
+ function evictForMemory(onClose?: (file: string) => void): void {
218
+ const currentMax = getCurrentMaxFiles();
219
+ while (files.size > currentMax) {
220
+ const sorted = getSortedFiles();
221
+ const lowest = sorted[sorted.length - 1];
222
+
223
+ if (!lowest) {
224
+ break;
225
+ }
226
+
227
+ files.delete(lowest.filePath);
228
+ if (onClose) {
229
+ onClose(lowest.filePath);
230
+ }
231
+
232
+ stats.totalEvictions++;
233
+ stats.evictionReasons.push("memory_pressure");
234
+ stats.openFileCount = files.size;
235
+ }
236
+ }
237
+
238
+ return {
239
+ open(filePath: string, onClose: (file: string) => void, mtime?: number): void {
240
+ // Check if file is excluded
241
+ if (isExcluded(filePath)) {
242
+ onClose(filePath);
243
+ stats.totalEvictions++;
244
+ stats.evictionReasons.push("excluded_type");
245
+ return;
246
+ }
247
+
248
+ stats.totalOpens++;
249
+
250
+ // Update access count
251
+ const currentCount = stats.accessCounts.get(filePath) ?? 0;
252
+ stats.accessCounts.set(filePath, currentCount + 1);
253
+
254
+ // If file already exists, update its metadata
255
+ if (files.has(filePath)) {
256
+ const entry = files.get(filePath)!;
257
+ entry.lastAccess = now();
258
+ entry.modifiedTime = mtime ?? entry.modifiedTime;
259
+ entry.accessCount++;
260
+ return;
261
+ }
262
+
263
+ // Evict files if needed (before adding new file)
264
+ evictIfNeeded(onClose);
265
+
266
+ // Add new file
267
+ const newFile: TrackedFile = {
268
+ filePath,
269
+ lastAccess: now(),
270
+ modifiedTime: mtime ?? now(),
271
+ accessCount: 1,
272
+ priority: getFilePriority(filePath),
273
+ };
274
+
275
+ files.set(filePath, newFile);
276
+ stats.openFileCount = files.size;
277
+ },
278
+
279
+ getOpenFiles(): string[] {
280
+ const sorted = getSortedFiles();
281
+ return sorted.map((f) => f.filePath);
282
+ },
283
+
284
+ closeAll(onClose: (file: string) => void): void {
285
+ for (const filePath of files.keys()) {
286
+ onClose(filePath);
287
+ }
288
+ files.clear();
289
+ stats.openFileCount = 0;
290
+ },
291
+
292
+ updateMemoryUsage(usage: MemoryUsage): void {
293
+ memoryUsage = usage;
294
+ // Evict files if memory pressure increased window size
295
+ evictForMemory();
296
+ },
297
+
298
+ getStatistics(): TrackerStatistics {
299
+ return { ...stats, accessCounts: new Map(stats.accessCounts) };
300
+ },
301
+ };
302
+ }