@oh-my-pi/pi-coding-agent 12.3.0 → 12.5.0

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 (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/custom-tools.md +21 -6
  3. package/docs/extensions.md +20 -0
  4. package/package.json +12 -12
  5. package/src/cli/setup-cli.ts +62 -2
  6. package/src/commands/setup.ts +1 -1
  7. package/src/config/keybindings.ts +6 -2
  8. package/src/config/settings-schema.ts +58 -4
  9. package/src/config/settings.ts +23 -9
  10. package/src/debug/index.ts +26 -19
  11. package/src/debug/log-formatting.ts +60 -0
  12. package/src/debug/log-viewer.ts +903 -0
  13. package/src/debug/report-bundle.ts +87 -8
  14. package/src/discovery/helpers.ts +131 -137
  15. package/src/extensibility/custom-tools/types.ts +44 -6
  16. package/src/extensibility/extensions/types.ts +60 -0
  17. package/src/extensibility/hooks/types.ts +60 -0
  18. package/src/extensibility/skills.ts +4 -2
  19. package/src/lsp/render.ts +1 -1
  20. package/src/main.ts +7 -1
  21. package/src/memories/index.ts +11 -7
  22. package/src/modes/components/bash-execution.ts +16 -9
  23. package/src/modes/components/custom-editor.ts +8 -0
  24. package/src/modes/components/python-execution.ts +16 -7
  25. package/src/modes/components/settings-selector.ts +29 -14
  26. package/src/modes/components/tool-execution.ts +2 -1
  27. package/src/modes/controllers/command-controller.ts +3 -1
  28. package/src/modes/controllers/event-controller.ts +7 -0
  29. package/src/modes/controllers/input-controller.ts +23 -2
  30. package/src/modes/controllers/selector-controller.ts +9 -7
  31. package/src/modes/interactive-mode.ts +84 -1
  32. package/src/modes/rpc/rpc-client.ts +7 -0
  33. package/src/modes/rpc/rpc-mode.ts +8 -0
  34. package/src/modes/rpc/rpc-types.ts +2 -0
  35. package/src/modes/theme/theme.ts +163 -7
  36. package/src/modes/types.ts +1 -0
  37. package/src/patch/hashline.ts +2 -1
  38. package/src/patch/shared.ts +44 -13
  39. package/src/prompts/system/plan-mode-approved.md +5 -0
  40. package/src/prompts/system/subagent-system-prompt.md +1 -0
  41. package/src/prompts/system/system-prompt.md +10 -0
  42. package/src/prompts/tools/todo-write.md +3 -1
  43. package/src/sdk.ts +82 -9
  44. package/src/session/agent-session.ts +137 -29
  45. package/src/session/streaming-output.ts +1 -1
  46. package/src/stt/downloader.ts +71 -0
  47. package/src/stt/index.ts +3 -0
  48. package/src/stt/recorder.ts +351 -0
  49. package/src/stt/setup.ts +52 -0
  50. package/src/stt/stt-controller.ts +160 -0
  51. package/src/stt/transcribe.py +70 -0
  52. package/src/stt/transcriber.ts +91 -0
  53. package/src/task/executor.ts +10 -2
  54. package/src/tools/bash-interactive.ts +10 -6
  55. package/src/tools/fetch.ts +1 -1
  56. package/src/tools/output-meta.ts +6 -2
  57. package/src/web/scrapers/types.ts +1 -0
@@ -7,15 +7,27 @@ import * as fs from "node:fs/promises";
7
7
  import * as path from "node:path";
8
8
  import type { WorkProfile } from "@oh-my-pi/pi-natives";
9
9
  import { isEnoent } from "@oh-my-pi/pi-utils";
10
- import { getLogPath, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
10
+ import { APP_NAME, getLogPath, getLogsDir, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
11
11
  import type { CpuProfile, HeapSnapshot } from "./profiler";
12
12
  import { collectSystemInfo, sanitizeEnv } from "./system-info";
13
13
 
14
- /** Read last N lines from a file */
15
- async function readLastLines(filePath: string, n: number): Promise<string> {
14
+ /** Maximum number of log lines to load into memory at once. */
15
+ const MAX_LOG_LINES = 5000;
16
+
17
+ /** Maximum bytes to read from the tail of a log file (2 MB). */
18
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
19
+ /** Read last N lines from a file, reading at most `maxBytes` from the tail. */
20
+ async function readLastLines(filePath: string, n: number, maxBytes = MAX_LOG_BYTES): Promise<string> {
16
21
  try {
17
- const content = await Bun.file(filePath).text();
22
+ const file = Bun.file(filePath);
23
+ const size = file.size;
24
+ const start = Math.max(0, size - maxBytes);
25
+ const content = start > 0 ? await file.slice(start, size).text() : await file.text();
18
26
  const lines = content.split("\n");
27
+ // If we sliced mid-file, drop the first (partial) line
28
+ if (start > 0 && lines.length > 0) {
29
+ lines.shift();
30
+ }
19
31
  return lines.slice(-n).join("\n");
20
32
  } catch (err) {
21
33
  if (isEnoent(err)) return "";
@@ -41,6 +53,12 @@ export interface ReportBundleResult {
41
53
  files: string[];
42
54
  }
43
55
 
56
+ export interface DebugLogSource {
57
+ getInitialText(): Promise<string>;
58
+ hasOlderLogs(): boolean;
59
+ loadOlderLogs(limitDays?: number): Promise<string>;
60
+ }
61
+
44
62
  /**
45
63
  * Create a debug report bundle.
46
64
  *
@@ -209,10 +227,71 @@ async function addSubagentSessions(
209
227
  }
210
228
  }
211
229
 
212
- /** Get recent log entries for display */
213
- export async function getRecentLogs(lines: number): Promise<string> {
214
- const logPath = getLogPath();
215
- return readLastLines(logPath, lines);
230
+ /** Get recent log entries for display (tail-limited to avoid OOM on large files). */
231
+ export async function getLogText(): Promise<string> {
232
+ return readLastLines(getLogPath(), MAX_LOG_LINES);
233
+ }
234
+
235
+ const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
236
+
237
+ export async function createDebugLogSource(): Promise<DebugLogSource> {
238
+ const logsDir = getLogsDir();
239
+ const todayPath = getLogPath();
240
+ const todayName = path.basename(todayPath);
241
+ let olderFiles: string[] = [];
242
+ try {
243
+ const entries = await fs.readdir(logsDir, { withFileTypes: true });
244
+ const datedFiles = entries
245
+ .filter(entry => entry.isFile())
246
+ .map(entry => {
247
+ const match = LOG_FILE_PATTERN.exec(entry.name);
248
+ return match ? { name: entry.name, date: match[1] } : undefined;
249
+ })
250
+ .filter((entry): entry is { name: string; date: string } => entry !== undefined)
251
+ .filter(entry => entry.name !== todayName)
252
+ .sort((a, b) => b.date.localeCompare(a.date));
253
+ olderFiles = datedFiles.map(entry => entry.name);
254
+ } catch {
255
+ olderFiles = [];
256
+ }
257
+
258
+ let cursor = 0;
259
+
260
+ const getInitialText = async (): Promise<string> => {
261
+ return readLastLines(todayPath, MAX_LOG_LINES);
262
+ };
263
+
264
+ const hasOlderLogs = (): boolean => cursor < olderFiles.length;
265
+
266
+ const loadOlderLogs = async (limitDays: number = 1): Promise<string> => {
267
+ if (!hasOlderLogs()) {
268
+ return "";
269
+ }
270
+ const count = Math.max(1, limitDays);
271
+ const slice = olderFiles.slice(cursor, cursor + count);
272
+ cursor += slice.length;
273
+ const chunks: string[] = [];
274
+ for (const filename of slice.reverse()) {
275
+ const filePath = path.join(logsDir, filename);
276
+ try {
277
+ const content = await readLastLines(filePath, MAX_LOG_LINES);
278
+ if (content.length > 0) {
279
+ chunks.push(content);
280
+ }
281
+ } catch (err) {
282
+ if (!isEnoent(err)) {
283
+ throw err;
284
+ }
285
+ }
286
+ }
287
+ return chunks.filter(chunk => chunk.length > 0).join("\n");
288
+ };
289
+
290
+ return {
291
+ getInitialText,
292
+ hasOlderLogs,
293
+ loadOlderLogs,
294
+ };
216
295
  }
217
296
 
218
297
  /** Calculate total size of artifact cache */
@@ -4,12 +4,13 @@
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import { FileType, glob } from "@oh-my-pi/pi-natives";
7
8
  import { CONFIG_DIR_NAME } from "@oh-my-pi/pi-utils/dirs";
8
- import { readDirEntries, readFile } from "../capability/fs";
9
+ import { readFile } from "../capability/fs";
9
10
  import type { Skill, SkillFrontmatter } from "../capability/skill";
10
11
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
11
12
  import { parseFrontmatter } from "../utils/frontmatter";
12
- import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
13
+ import type { IgnoreMatcher } from "../utils/ignore-files";
13
14
 
14
15
  const VALID_THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
15
16
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -224,6 +225,20 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
224
225
  return { name, description, tools, spawns, model, output, thinkingLevel };
225
226
  }
226
227
 
228
+ async function globIf(
229
+ dir: string,
230
+ pattern: string,
231
+ fileType: FileType,
232
+ recursive: boolean = true,
233
+ ): Promise<Array<{ path: string }>> {
234
+ try {
235
+ const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType, recursive });
236
+ return result.matches;
237
+ } catch {
238
+ return [];
239
+ }
240
+ }
241
+
227
242
  export async function loadSkillsFromDir(
228
243
  _ctx: LoadContext,
229
244
  options: {
@@ -236,39 +251,42 @@ export async function loadSkillsFromDir(
236
251
  const items: Skill[] = [];
237
252
  const warnings: string[] = [];
238
253
  const { dir, level, providerId, requireDescription = false } = options;
254
+ // Use native glob to find all SKILL.md files one level deep
255
+ // Pattern */SKILL.md matches <dir>/<subdir>/SKILL.md
256
+ const discoveredMatches = new Set<string>();
257
+ for (const match of await globIf(dir, "*/SKILL.md", FileType.File)) {
258
+ discoveredMatches.add(match.path);
259
+ }
260
+ for (const match of await globIf(dir, "*", FileType.Dir, false)) {
261
+ const skillRelPath = `${match.path}/SKILL.md`;
262
+ const content = await readFile(path.join(dir, skillRelPath));
263
+ if (content !== null) {
264
+ discoveredMatches.add(skillRelPath);
265
+ }
266
+ }
267
+ const matches = [...discoveredMatches].map(path => ({ path }));
268
+ if (matches.length === 0) {
269
+ return { items, warnings };
270
+ }
239
271
 
240
- // Initialize ignore matcher and read ignore rules from root
241
- const ig = createIgnoreMatcher();
242
- await addIgnoreRules(ig, dir, dir, readFile);
243
-
244
- const entries = await readDirEntries(dir);
245
- const skillDirs = entries.filter(
246
- entry => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
247
- );
248
-
272
+ // Read all skill files in parallel
249
273
  const results = await Promise.all(
250
- skillDirs.map(async entry => {
251
- const entryPath = path.join(dir, entry.name);
252
-
253
- // Check if this directory should be ignored
254
- if (shouldIgnore(ig, dir, entryPath, true)) {
255
- return { item: null as Skill | null, warning: null as string | null };
256
- }
257
-
258
- const skillFile = path.join(entryPath, "SKILL.md");
274
+ matches.map(async match => {
275
+ const skillFile = path.join(dir, match.path);
259
276
  const content = await readFile(skillFile);
260
277
  if (!content) {
261
278
  return { item: null as Skill | null, warning: null as string | null };
262
279
  }
263
-
264
280
  const { frontmatter, body } = parseFrontmatter(content, { source: skillFile });
265
281
  if (requireDescription && !frontmatter.description) {
266
282
  return { item: null as Skill | null, warning: null as string | null };
267
283
  }
268
284
 
285
+ // Extract skill name from path: "<skilldir>/SKILL.md" -> "<skilldir>"
286
+ const skillDirName = path.basename(path.dirname(skillFile));
269
287
  return {
270
288
  item: {
271
- name: (frontmatter.name as string) || entry.name,
289
+ name: (frontmatter.name as string) || skillDirName,
272
290
  path: skillFile,
273
291
  content: body,
274
292
  frontmatter: frontmatter as SkillFrontmatter,
@@ -279,12 +297,10 @@ export async function loadSkillsFromDir(
279
297
  };
280
298
  }),
281
299
  );
282
-
283
300
  for (const result of results) {
284
301
  if (result.warning) warnings.push(result.warning);
285
302
  if (result.item) items.push(result.item);
286
303
  }
287
-
288
304
  return { items, warnings };
289
305
  }
290
306
 
@@ -322,8 +338,8 @@ export function expandEnvVarsDeep<T>(obj: T, extraEnv?: Record<string, string>):
322
338
  }
323
339
 
324
340
  /**
325
- * Load files from a directory matching a pattern.
326
- * Respects .gitignore, .ignore, and .fdignore files.
341
+ * Load files from a directory matching extensions.
342
+ * Uses native glob for fast filesystem scanning with gitignore support.
327
343
  */
328
344
  export async function loadFilesFromDir<T>(
329
345
  _ctx: LoadContext,
@@ -335,85 +351,70 @@ export async function loadFilesFromDir<T>(
335
351
  extensions?: string[];
336
352
  /** Transform file to item (return null to skip) */
337
353
  transform: (name: string, content: string, path: string, source: SourceMeta) => T | null;
338
- /** Whether to recurse into subdirectories */
354
+ /** Whether to recurse into subdirectories (default: false) */
339
355
  recursive?: boolean;
340
- /** Root directory for ignore file handling (defaults to dir) */
356
+ /** Root directory for ignore file handling (unused, kept for API compat) */
341
357
  rootDir?: string;
342
- /** Ignore matcher (used internally for recursion) */
358
+ /** Ignore matcher (unused, kept for API compat) */
343
359
  ignoreMatcher?: IgnoreMatcher;
344
360
  },
345
361
  ): Promise<LoadResult<T>> {
346
- const rootDir = options.rootDir ?? dir;
347
- const ig = options.ignoreMatcher ?? createIgnoreMatcher();
348
-
349
- // Read ignore rules from this directory
350
- await addIgnoreRules(ig, dir, rootDir, readFile);
351
-
352
- const entries = await readDirEntries(dir);
353
-
354
- const visibleEntries = entries.filter(entry => !entry.name.startsWith("."));
355
-
356
- const directories = options.recursive
357
- ? visibleEntries.filter(entry => {
358
- if (!entry.isDirectory()) return false;
359
- const entryPath = path.join(dir, entry.name);
360
- return !shouldIgnore(ig, rootDir, entryPath, true);
361
- })
362
- : [];
363
-
364
- const files = visibleEntries.filter(entry => {
365
- if (!entry.isFile()) return false;
366
- const entryPath = path.join(dir, entry.name);
367
- if (shouldIgnore(ig, rootDir, entryPath, false)) return false;
368
- if (!options.extensions) return true;
369
- return options.extensions.some(ext => entry.name.endsWith(`.${ext}`));
370
- });
371
-
372
- const [subResults, fileResults] = await Promise.all([
373
- Promise.all(
374
- directories.map(entry =>
375
- loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, {
376
- ...options,
377
- rootDir,
378
- ignoreMatcher: ig,
379
- }),
380
- ),
381
- ),
382
- Promise.all(
383
- files.map(async entry => {
384
- const filePath = path.join(dir, entry.name);
385
- const content = await readFile(filePath);
386
- return { entry, path: filePath, content };
387
- }),
388
- ),
389
- ]);
390
-
391
362
  const items: T[] = [];
392
363
  const warnings: string[] = [];
364
+ // Build glob pattern based on extensions and recursion
365
+ const { extensions, recursive = false } = options;
393
366
 
394
- for (const subResult of subResults) {
395
- items.push(...subResult.items);
396
- if (subResult.warnings) warnings.push(...subResult.warnings);
367
+ let pattern: string;
368
+ if (extensions && extensions.length > 0) {
369
+ const extPattern = extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`;
370
+ pattern = recursive ? `**/*.${extPattern}` : `*.${extPattern}`;
371
+ } else {
372
+ pattern = recursive ? "**/*" : "*";
397
373
  }
398
374
 
399
- for (const { entry, path, content } of fileResults) {
375
+ // Use native glob for fast scanning with gitignore support
376
+ let matches: Array<{ path: string }>;
377
+ try {
378
+ const result = await glob({
379
+ pattern,
380
+ path: dir,
381
+ gitignore: true,
382
+ hidden: false,
383
+ fileType: FileType.File,
384
+ });
385
+ matches = result.matches;
386
+ } catch {
387
+ // Directory doesn't exist or isn't readable
388
+ return { items, warnings };
389
+ }
390
+
391
+ // Read all matching files in parallel
392
+ const fileResults = await Promise.all(
393
+ matches.map(async match => {
394
+ const filePath = path.join(dir, match.path);
395
+ const content = await readFile(filePath);
396
+ return { filePath, content };
397
+ }),
398
+ );
399
+
400
+ for (const { filePath, content } of fileResults) {
400
401
  if (content === null) {
401
- warnings.push(`Failed to read file: ${path}`);
402
+ warnings.push(`Failed to read file: ${filePath}`);
402
403
  continue;
403
404
  }
404
405
 
405
- const source = createSourceMeta(provider, path, level);
406
+ const name = path.basename(filePath);
407
+ const source = createSourceMeta(provider, filePath, level);
406
408
 
407
409
  try {
408
- const item = options.transform(entry.name, content, path, source);
410
+ const item = options.transform(name, content, filePath, source);
409
411
  if (item !== null) {
410
412
  items.push(item);
411
413
  }
412
414
  } catch (err) {
413
- warnings.push(`Failed to parse ${path}: ${err}`);
415
+ warnings.push(`Failed to parse ${filePath}: ${err}`);
414
416
  }
415
417
  }
416
-
417
418
  return { items, warnings };
418
419
  }
419
420
 
@@ -458,10 +459,6 @@ async function readExtensionModuleManifest(
458
459
  return null;
459
460
  }
460
461
 
461
- function isExtensionModuleFile(name: string): boolean {
462
- return name.endsWith(".ts") || name.endsWith(".js");
463
- }
464
-
465
462
  /**
466
463
  * Discover extension module entry points in a directory.
467
464
  *
@@ -471,61 +468,58 @@ function isExtensionModuleFile(name: string): boolean {
471
468
  * 3. Subdirectory with package.json: `extensions/<ext>/package.json` with "omp"/"pi" field → load declared paths
472
469
  *
473
470
  * No recursion beyond one level. Complex packages must use package.json manifest.
474
- * Respects .gitignore, .ignore, and .fdignore files.
471
+ * Uses native glob for fast filesystem scanning with gitignore support.
475
472
  */
476
- export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string): Promise<string[]> {
477
- const discovered: string[] = [];
478
- const entries = await readDirEntries(dir);
479
-
480
- // Initialize ignore matcher and read ignore rules from root
481
- const ig = createIgnoreMatcher();
482
- await addIgnoreRules(ig, dir, dir, readFile);
483
-
484
- for (const entry of entries) {
485
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
486
-
487
- const entryPath = path.join(dir, entry.name);
488
-
489
- // 1. Direct files: *.ts or *.js
490
- if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionModuleFile(entry.name)) {
491
- if (shouldIgnore(ig, dir, entryPath, false)) continue;
492
- discovered.push(entryPath);
493
- continue;
494
- }
495
-
496
- // 2 & 3. Subdirectories
497
- if (entry.isDirectory() || entry.isSymbolicLink()) {
498
- if (shouldIgnore(ig, dir, entryPath, true)) continue;
499
-
500
- const subEntries = await readDirEntries(entryPath);
501
- const subFileNames = new Set(subEntries.filter(e => e.isFile()).map(e => e.name));
502
-
503
- // Check for package.json with "omp"/"pi" field first
504
- if (subFileNames.has("package.json")) {
505
- const packageJsonPath = path.join(entryPath, "package.json");
506
- const manifest = await readExtensionModuleManifest(ctx, packageJsonPath);
507
- if (manifest?.extensions && Array.isArray(manifest.extensions)) {
508
- for (const extPath of manifest.extensions) {
509
- const resolvedExtPath = path.resolve(entryPath, extPath);
510
- const content = await readFile(resolvedExtPath);
511
- if (content !== null) {
512
- discovered.push(resolvedExtPath);
513
- }
514
- }
515
- continue;
516
- }
517
- }
473
+ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise<string[]> {
474
+ const discovered = new Set<string>();
475
+ // Find all candidate files in parallel using glob
476
+ const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([
477
+ // 1. Direct *.ts or *.js files
478
+ globIf(dir, "*.{ts,js}", FileType.File, false),
479
+ // 2. Subdirectory index files
480
+ globIf(dir, "*/index.{ts,js}", FileType.File),
481
+ // 3. Subdirectory package.json files
482
+ globIf(dir, "*/package.json", FileType.File),
483
+ ]);
518
484
 
519
- // Check for index.ts or index.js
520
- if (subFileNames.has("index.ts")) {
521
- discovered.push(path.join(entryPath, "index.ts"));
522
- } else if (subFileNames.has("index.js")) {
523
- discovered.push(path.join(entryPath, "index.js"));
485
+ // Process direct files
486
+ for (const match of directFiles) {
487
+ if (match.path.includes("/")) continue;
488
+ discovered.add(path.join(dir, match.path));
489
+ }
490
+ // Track which subdirectories have package.json manifests with declared extensions
491
+ const subdirsWithDeclaredExtensions = new Set<string>();
492
+ for (const match of packageJsonFiles) {
493
+ const subdir = path.dirname(match.path); // e.g., "my-extension"
494
+ const packageJsonPath = path.join(dir, match.path);
495
+ const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath);
496
+ const declaredExtensions =
497
+ manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? [];
498
+ if (declaredExtensions.length === 0) continue;
499
+ subdirsWithDeclaredExtensions.add(subdir);
500
+ const subdirPath = path.join(dir, subdir);
501
+ for (const extPath of declaredExtensions) {
502
+ const resolvedExtPath = path.resolve(subdirPath, extPath);
503
+ const content = await readFile(resolvedExtPath);
504
+ if (content !== null) {
505
+ discovered.add(resolvedExtPath);
524
506
  }
525
507
  }
526
508
  }
527
-
528
- return discovered;
509
+ const preferredIndexBySubdir = new Map<string, string>();
510
+ for (const match of indexFiles) {
511
+ if (match.path.split("/").length !== 2) continue;
512
+ const subdir = path.dirname(match.path);
513
+ if (subdirsWithDeclaredExtensions.has(subdir)) continue;
514
+ const existing = preferredIndexBySubdir.get(subdir);
515
+ if (!existing || (existing.endsWith("index.js") && match.path.endsWith("index.ts"))) {
516
+ preferredIndexBySubdir.set(subdir, match.path);
517
+ }
518
+ }
519
+ for (const preferredPath of preferredIndexBySubdir.values()) {
520
+ discovered.add(path.join(dir, preferredPath));
521
+ }
522
+ return [...discovered];
529
523
  }
530
524
 
531
525
  /**
@@ -8,11 +8,14 @@ import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agen
8
8
  import type { Model } from "@oh-my-pi/pi-ai";
9
9
  import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import type { Static, TSchema } from "@sinclair/typebox";
11
+ import type { Rule } from "../../capability/rule";
11
12
  import type { ModelRegistry } from "../../config/model-registry";
12
13
  import type { ExecOptions, ExecResult } from "../../exec/exec";
13
14
  import type { HookUIContext } from "../../extensibility/hooks/types";
14
15
  import type { Theme } from "../../modes/theme/theme";
16
+ import type { CompactionResult } from "../../session/compaction";
15
17
  import type { ReadonlySessionManager } from "../../session/session-manager";
18
+ import type { TodoItem } from "../../tools/todo-write";
16
19
 
17
20
  /** Alias for clarity */
18
21
  export type CustomToolUIContext = HookUIContext;
@@ -61,12 +64,47 @@ export interface CustomToolContext {
61
64
  }
62
65
 
63
66
  /** Session event passed to onSession callback */
64
- export interface CustomToolSessionEvent {
65
- /** Reason for the session event */
66
- reason: "start" | "switch" | "branch" | "tree" | "shutdown";
67
- /** Previous session file path, or undefined for "start" and "shutdown" */
68
- previousSessionFile: string | undefined;
69
- }
67
+ export type CustomToolSessionEvent =
68
+ | {
69
+ /** Reason for the session event */
70
+ reason: "start" | "switch" | "branch" | "tree" | "shutdown";
71
+ /** Previous session file path, or undefined for "start" and "shutdown" */
72
+ previousSessionFile: string | undefined;
73
+ }
74
+ | {
75
+ reason: "auto_compaction_start";
76
+ trigger: "threshold" | "overflow";
77
+ }
78
+ | {
79
+ reason: "auto_compaction_end";
80
+ result: CompactionResult | undefined;
81
+ aborted: boolean;
82
+ willRetry: boolean;
83
+ errorMessage?: string;
84
+ }
85
+ | {
86
+ reason: "auto_retry_start";
87
+ attempt: number;
88
+ maxAttempts: number;
89
+ delayMs: number;
90
+ errorMessage: string;
91
+ }
92
+ | {
93
+ reason: "auto_retry_end";
94
+ success: boolean;
95
+ attempt: number;
96
+ finalError?: string;
97
+ }
98
+ | {
99
+ reason: "ttsr_triggered";
100
+ rules: Rule[];
101
+ }
102
+ | {
103
+ reason: "todo_reminder";
104
+ todos: TodoItem[];
105
+ attempt: number;
106
+ maxAttempts: number;
107
+ };
70
108
 
71
109
  /** Rendering options passed to renderResult */
72
110
  export interface RenderResultOptions {
@@ -12,6 +12,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my
12
12
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
13
13
  import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
14
14
  import type { Static, TSchema } from "@sinclair/typebox";
15
+ import type { Rule } from "../../capability/rule";
15
16
  import type { KeybindingsManager } from "../../config/keybindings";
16
17
  import type { ModelRegistry } from "../../config/model-registry";
17
18
  import type { BashResult } from "../../exec/bash-executor";
@@ -39,6 +40,7 @@ import type {
39
40
  ReadToolInput,
40
41
  WriteToolInput,
41
42
  } from "../../tools";
43
+ import type { TodoItem } from "../../tools/todo-write";
42
44
  import type { EventBus } from "../../utils/event-bus";
43
45
  import type { SlashCommandInfo } from "../slash-commands";
44
46
 
@@ -459,6 +461,52 @@ export interface TurnEndEvent {
459
461
  toolResults: ToolResultMessage[];
460
462
  }
461
463
 
464
+ /** Fired when auto-compaction starts */
465
+ export interface AutoCompactionStartEvent {
466
+ type: "auto_compaction_start";
467
+ reason: "threshold" | "overflow";
468
+ }
469
+
470
+ /** Fired when auto-compaction ends */
471
+ export interface AutoCompactionEndEvent {
472
+ type: "auto_compaction_end";
473
+ result: CompactionResult | undefined;
474
+ aborted: boolean;
475
+ willRetry: boolean;
476
+ errorMessage?: string;
477
+ }
478
+
479
+ /** Fired when auto-retry starts */
480
+ export interface AutoRetryStartEvent {
481
+ type: "auto_retry_start";
482
+ attempt: number;
483
+ maxAttempts: number;
484
+ delayMs: number;
485
+ errorMessage: string;
486
+ }
487
+
488
+ /** Fired when auto-retry ends */
489
+ export interface AutoRetryEndEvent {
490
+ type: "auto_retry_end";
491
+ success: boolean;
492
+ attempt: number;
493
+ finalError?: string;
494
+ }
495
+
496
+ /** Fired when TTSR rule matching interrupts generation */
497
+ export interface TtsrTriggeredEvent {
498
+ type: "ttsr_triggered";
499
+ rules: Rule[];
500
+ }
501
+
502
+ /** Fired when todo reminder logic detects unfinished todos */
503
+ export interface TodoReminderEvent {
504
+ type: "todo_reminder";
505
+ todos: TodoItem[];
506
+ attempt: number;
507
+ maxAttempts: number;
508
+ }
509
+
462
510
  // ============================================================================
463
511
  // User Bash Events
464
512
  // ============================================================================
@@ -652,6 +700,12 @@ export type ExtensionEvent =
652
700
  | AgentEndEvent
653
701
  | TurnStartEvent
654
702
  | TurnEndEvent
703
+ | AutoCompactionStartEvent
704
+ | AutoCompactionEndEvent
705
+ | AutoRetryStartEvent
706
+ | AutoRetryEndEvent
707
+ | TtsrTriggeredEvent
708
+ | TodoReminderEvent
655
709
  | UserBashEvent
656
710
  | UserPythonEvent
657
711
  | InputEvent
@@ -814,6 +868,12 @@ export interface ExtensionAPI {
814
868
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
815
869
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
816
870
  on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
871
+ on(event: "auto_compaction_start", handler: ExtensionHandler<AutoCompactionStartEvent>): void;
872
+ on(event: "auto_compaction_end", handler: ExtensionHandler<AutoCompactionEndEvent>): void;
873
+ on(event: "auto_retry_start", handler: ExtensionHandler<AutoRetryStartEvent>): void;
874
+ on(event: "auto_retry_end", handler: ExtensionHandler<AutoRetryEndEvent>): void;
875
+ on(event: "ttsr_triggered", handler: ExtensionHandler<TtsrTriggeredEvent>): void;
876
+ on(event: "todo_reminder", handler: ExtensionHandler<TodoReminderEvent>): void;
817
877
  on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
818
878
  on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
819
879
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;