@oh-my-pi/pi-coding-agent 4.1.0 → 4.2.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 (90) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-session.ts +3 -3
  7. package/src/core/agent-storage.ts +450 -0
  8. package/src/core/auth-storage.ts +102 -183
  9. package/src/core/compaction/branch-summarization.ts +5 -4
  10. package/src/core/compaction/compaction.ts +7 -6
  11. package/src/core/compaction/utils.ts +6 -11
  12. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  13. package/src/core/custom-share.ts +66 -0
  14. package/src/core/export-html/index.ts +1 -33
  15. package/src/core/history-storage.ts +15 -7
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +130 -290
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/complete.ts +5 -2
  27. package/src/core/tools/edit.ts +2 -1
  28. package/src/core/tools/find.ts +2 -1
  29. package/src/core/tools/gemini-image.ts +2 -1
  30. package/src/core/tools/git.ts +2 -2
  31. package/src/core/tools/grep.ts +2 -1
  32. package/src/core/tools/index.test.ts +0 -28
  33. package/src/core/tools/index.ts +0 -6
  34. package/src/core/tools/lsp/index.ts +2 -1
  35. package/src/core/tools/output.ts +2 -1
  36. package/src/core/tools/read.ts +4 -1
  37. package/src/core/tools/ssh.ts +4 -2
  38. package/src/core/tools/task/agents.ts +56 -30
  39. package/src/core/tools/task/commands.ts +5 -8
  40. package/src/core/tools/task/index.ts +7 -15
  41. package/src/core/tools/web-fetch.ts +2 -1
  42. package/src/core/tools/web-search/auth.ts +106 -16
  43. package/src/core/tools/web-search/index.ts +3 -2
  44. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  45. package/src/core/tools/write.ts +2 -1
  46. package/src/core/voice.ts +3 -1
  47. package/src/discovery/builtin.ts +9 -54
  48. package/src/discovery/claude.ts +16 -69
  49. package/src/discovery/codex.ts +11 -36
  50. package/src/discovery/helpers.ts +52 -1
  51. package/src/main.ts +1 -1
  52. package/src/migrations.ts +20 -20
  53. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  54. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  55. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  56. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  57. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  58. package/src/modes/interactive/interactive-mode.ts +363 -3139
  59. package/src/modes/interactive/theme/theme.ts +5 -5
  60. package/src/modes/interactive/types.ts +189 -0
  61. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  62. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  63. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  64. package/src/prompts/agents/frontmatter.md +7 -0
  65. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  66. package/src/prompts/agents/planner.md +112 -0
  67. package/src/prompts/agents/task.md +15 -0
  68. package/src/prompts/review-request.md +44 -8
  69. package/src/prompts/system/custom-system-prompt.md +80 -0
  70. package/src/prompts/system/file-operations.md +12 -0
  71. package/src/prompts/system/system-prompt.md +237 -0
  72. package/src/prompts/system/title-system.md +2 -0
  73. package/src/prompts/tools/bash.md +1 -1
  74. package/src/prompts/tools/read.md +1 -1
  75. package/src/prompts/tools/task.md +34 -22
  76. package/src/core/tools/rulebook.ts +0 -132
  77. package/src/prompts/architect-plan.md +0 -10
  78. package/src/prompts/implement-with-critic.md +0 -11
  79. package/src/prompts/implement.md +0 -11
  80. package/src/prompts/system-prompt.md +0 -43
  81. package/src/prompts/task.md +0 -14
  82. package/src/prompts/title-system.md +0 -8
  83. /package/src/prompts/{init.md → agents/init.md} +0 -0
  84. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  85. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  86. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  87. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  88. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  89. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  90. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -1,23 +1,9 @@
1
1
  /**
2
2
  * Credential storage for API keys and OAuth tokens.
3
- * Handles loading, saving, and refreshing credentials from auth.json.
4
- *
5
- * Uses file locking to prevent race conditions when multiple pi instances
6
- * try to refresh tokens simultaneously.
3
+ * Handles loading, saving, and refreshing credentials from agent.db.
7
4
  */
8
5
 
9
- import {
10
- chmodSync,
11
- closeSync,
12
- existsSync,
13
- openSync,
14
- readFileSync,
15
- renameSync,
16
- statSync,
17
- unlinkSync,
18
- writeFileSync,
19
- } from "node:fs";
20
- import { dirname } from "node:path";
6
+ import { dirname, join } from "node:path";
21
7
  import {
22
8
  getEnvApiKey,
23
9
  getOAuthApiKey,
@@ -29,7 +15,10 @@ import {
29
15
  type OAuthCredentials,
30
16
  type OAuthProvider,
31
17
  } from "@oh-my-pi/pi-ai";
18
+ import { getAgentDbPath } from "../config";
19
+ import { AgentStorage } from "./agent-storage";
32
20
  import { logger } from "./logger";
21
+ import { migrateJsonStorage } from "./storage-migration";
33
22
 
34
23
  export type ApiKeyCredential = {
35
24
  type: "api_key";
@@ -46,6 +35,12 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
46
35
 
47
36
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
48
37
 
38
+ /**
39
+ * In-memory representation pairing DB row ID with credential.
40
+ * The ID is required for update/delete operations against agent.db.
41
+ */
42
+ type StoredCredential = { id: number; credential: AuthCredential };
43
+
49
44
  /** Rate limit window from Codex usage API (primary or secondary quota). */
50
45
  type CodexUsageWindow = {
51
46
  usedPercent?: number;
@@ -86,18 +81,18 @@ function toBoolean(value: unknown): boolean | undefined {
86
81
  }
87
82
 
88
83
  /**
89
- * Credential storage backed by a JSON file.
90
- * Reads from multiple fallback paths, writes to primary path.
84
+ * Credential storage backed by agent.db.
85
+ * Reads from SQLite and migrates legacy auth.json paths.
91
86
  */
92
87
  export class AuthStorage {
93
- // File locking configuration for concurrent access protection
94
- private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock
95
- private static readonly lockTimeoutMs = 5000; // Max wait time before failing
96
- private static readonly lockStaleMs = 30000; // Age threshold for auto-removing orphaned locks
97
88
  private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
98
89
  private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
99
90
 
100
- private data: AuthStorageData = {};
91
+ /** Provider -> credentials cache, populated from agent.db on reload(). */
92
+ private data: Map<string, StoredCredential[]> = new Map();
93
+ private storage: AgentStorage;
94
+ /** Resolved path to agent.db (derived from authPath or used directly if .db). */
95
+ private dbPath: string;
101
96
  private runtimeOverrides: Map<string, string> = new Map();
102
97
  /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
103
98
  private providerRoundRobinIndex: Map<string, number> = new Map();
@@ -110,13 +105,28 @@ export class AuthStorage {
110
105
  private fallbackResolver?: (provider: string) => string | undefined;
111
106
 
112
107
  /**
113
- * @param authPath - Primary path for reading/writing auth.json
114
- * @param fallbackPaths - Additional paths to check when reading (legacy support)
108
+ * @param authPath - Legacy auth.json path used for migration and locating agent.db
109
+ * @param fallbackPaths - Additional auth.json paths to migrate (legacy support)
115
110
  */
116
111
  constructor(
117
112
  private authPath: string,
118
113
  private fallbackPaths: string[] = [],
119
- ) {}
114
+ ) {
115
+ this.dbPath = AuthStorage.resolveDbPath(authPath);
116
+ this.storage = AgentStorage.open(this.dbPath);
117
+ }
118
+
119
+ /**
120
+ * Converts legacy auth.json path to agent.db path, or returns .db path as-is.
121
+ * @param authPath - Path to auth.json or agent.db
122
+ * @returns Resolved path to agent.db
123
+ */
124
+ private static resolveDbPath(authPath: string): string {
125
+ if (authPath.endsWith(".db")) {
126
+ return authPath;
127
+ }
128
+ return getAgentDbPath(dirname(authPath));
129
+ }
120
130
 
121
131
  /**
122
132
  * Set a runtime API key override (not persisted to disk).
@@ -134,7 +144,7 @@ export class AuthStorage {
134
144
  }
135
145
 
136
146
  /**
137
- * Set a fallback resolver for API keys not found in auth.json or env vars.
147
+ * Set a fallback resolver for API keys not found in agent.db or env vars.
138
148
  * Used for custom provider keys from models.json.
139
149
  */
140
150
  setFallbackResolver(resolver: (provider: string) => string | undefined): void {
@@ -142,138 +152,53 @@ export class AuthStorage {
142
152
  }
143
153
 
144
154
  /**
145
- * Reload credentials from disk.
146
- * Checks primary path first, then fallback paths.
155
+ * Reload credentials from agent.db.
156
+ * Migrates legacy auth.json/settings.json on first load.
147
157
  */
148
158
  async reload(): Promise<void> {
149
- const pathsToCheck = [this.authPath, ...this.fallbackPaths];
150
-
151
- logger.debug("AuthStorage.reload checking paths", { paths: pathsToCheck });
152
-
153
- for (const authPath of pathsToCheck) {
154
- const exists = existsSync(authPath);
155
- logger.debug("AuthStorage.reload path check", { path: authPath, exists });
156
-
157
- if (exists) {
158
- try {
159
- this.data = JSON.parse(readFileSync(authPath, "utf-8"));
160
- logger.debug("AuthStorage.reload loaded", { path: authPath, providers: Object.keys(this.data) });
161
- return;
162
- } catch (e) {
163
- logger.error("AuthStorage failed to parse auth file", { path: authPath, error: String(e) });
164
- // Continue to next path on parse error
165
- }
166
- }
167
- }
159
+ const agentDir = dirname(this.dbPath);
160
+ await migrateJsonStorage({
161
+ agentDir,
162
+ settingsPath: join(agentDir, "settings.json"),
163
+ authPaths: [this.authPath, ...this.fallbackPaths],
164
+ });
168
165
 
169
- logger.warn("AuthStorage no auth file found", { checkedPaths: pathsToCheck });
170
- this.data = {};
166
+ const records = this.storage.listAuthCredentials();
167
+ const grouped = new Map<string, StoredCredential[]>();
168
+ for (const record of records) {
169
+ const list = grouped.get(record.provider) ?? [];
170
+ list.push({ id: record.id, credential: record.credential });
171
+ grouped.set(record.provider, list);
172
+ }
173
+ this.data = grouped;
171
174
  }
172
175
 
173
176
  /**
174
- * Save credentials to disk.
177
+ * Gets cached credentials for a provider.
178
+ * @param provider - Provider name (e.g., "anthropic", "openai")
179
+ * @returns Array of stored credentials, empty if none exist
175
180
  */
176
- private async save(): Promise<void> {
177
- const lockFd = await this.acquireLock();
178
- const tempPath = this.getTempPath();
179
-
180
- try {
181
- writeFileSync(tempPath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
182
- renameSync(tempPath, this.authPath);
183
- chmodSync(this.authPath, 0o600);
184
- const dir = dirname(this.authPath);
185
- chmodSync(dir, 0o700);
186
- } finally {
187
- this.safeUnlink(tempPath);
188
- this.releaseLock(lockFd);
189
- }
190
- }
191
-
192
- /** Returns the lock file path (auth.json.lock) */
193
- private getLockPath(): string {
194
- return `${this.authPath}.lock`;
195
- }
196
-
197
- /** Returns a unique temp file path using pid and timestamp to avoid collisions */
198
- private getTempPath(): string {
199
- return `${this.authPath}.tmp-${process.pid}-${Date.now()}`;
200
- }
201
-
202
- /** Checks if lock file is older than lockStaleMs (orphaned by crashed process) */
203
- private isLockStale(lockPath: string): boolean {
204
- try {
205
- const stats = statSync(lockPath);
206
- return Date.now() - stats.mtimeMs > AuthStorage.lockStaleMs;
207
- } catch {
208
- return false;
209
- }
181
+ private getStoredCredentials(provider: string): StoredCredential[] {
182
+ return this.data.get(provider) ?? [];
210
183
  }
211
184
 
212
185
  /**
213
- * Acquires exclusive file lock using O_EXCL atomic create.
214
- * Polls with exponential backoff, removes stale locks from crashed processes.
215
- * @returns File descriptor for the lock (must be passed to releaseLock)
186
+ * Updates in-memory credential cache for a provider.
187
+ * Removes the provider entry entirely if credentials array is empty.
188
+ * @param provider - Provider name (e.g., "anthropic", "openai")
189
+ * @param credentials - Array of stored credentials to cache
216
190
  */
217
- private async acquireLock(): Promise<number> {
218
- const lockPath = this.getLockPath();
219
- const start = Date.now();
220
- const timeoutMs = AuthStorage.lockTimeoutMs;
221
- const retryDelayMs = AuthStorage.lockRetryDelayMs;
222
-
223
- while (true) {
224
- try {
225
- // O_EXCL fails if file exists, providing atomic lock acquisition
226
- return openSync(lockPath, "wx", 0o600);
227
- } catch (error) {
228
- const err = error as NodeJS.ErrnoException;
229
- if (err.code !== "EEXIST") {
230
- throw err;
231
- }
232
- if (this.isLockStale(lockPath)) {
233
- this.safeUnlink(lockPath);
234
- logger.warn("AuthStorage lock was stale, removing", { path: lockPath });
235
- continue;
236
- }
237
- if (Date.now() - start > timeoutMs) {
238
- throw new Error(`Timed out waiting for auth lock: ${lockPath}`);
239
- }
240
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
241
- }
242
- }
243
- }
244
-
245
- /** Releases file lock by closing fd and removing lock file */
246
- private releaseLock(lockFd: number): void {
247
- const lockPath = this.getLockPath();
248
- try {
249
- closeSync(lockFd);
250
- } catch (error) {
251
- logger.warn("AuthStorage failed to close lock file", { error: String(error) });
252
- }
253
- this.safeUnlink(lockPath);
254
- }
255
-
256
- /** Removes file if it exists, ignoring ENOENT errors */
257
- private safeUnlink(path: string): void {
258
- try {
259
- unlinkSync(path);
260
- } catch (error) {
261
- const err = error as NodeJS.ErrnoException;
262
- if (err.code !== "ENOENT") {
263
- logger.warn("AuthStorage failed to remove file", { path, error: String(error) });
264
- }
191
+ private setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
192
+ if (credentials.length === 0) {
193
+ this.data.delete(provider);
194
+ } else {
195
+ this.data.set(provider, credentials);
265
196
  }
266
197
  }
267
198
 
268
- /** Normalizes credential storage format: single credential becomes array of one */
269
- private normalizeCredentialEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
270
- if (!entry) return [];
271
- return Array.isArray(entry) ? entry : [entry];
272
- }
273
-
274
199
  /** Returns all credentials for a provider as an array */
275
200
  private getCredentialsForProvider(provider: string): AuthCredential[] {
276
- return this.normalizeCredentialEntry(this.data[provider]);
201
+ return this.getStoredCredentials(provider).map((entry) => entry.credential);
277
202
  }
278
203
 
279
204
  /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
@@ -423,21 +348,13 @@ export class AuthStorage {
423
348
 
424
349
  /** Updates credential at index in-place (used for OAuth token refresh) */
425
350
  private replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
426
- const entry = this.data[provider];
427
- if (!entry) return;
428
-
429
- if (Array.isArray(entry)) {
430
- if (index >= 0 && index < entry.length) {
431
- const updated = [...entry];
432
- updated[index] = credential;
433
- this.data[provider] = updated;
434
- }
435
- return;
436
- }
437
-
438
- if (index === 0) {
439
- this.data[provider] = credential;
440
- }
351
+ const entries = this.getStoredCredentials(provider);
352
+ if (index < 0 || index >= entries.length) return;
353
+ const target = entries[index];
354
+ this.storage.updateAuthCredential(target.id, credential);
355
+ const updated = [...entries];
356
+ updated[index] = { id: target.id, credential };
357
+ this.setStoredCredentials(provider, updated);
441
358
  }
442
359
 
443
360
  /**
@@ -445,20 +362,11 @@ export class AuthStorage {
445
362
  * Cleans up provider entry if last credential removed.
446
363
  */
447
364
  private removeCredentialAt(provider: string, index: number): void {
448
- const entry = this.data[provider];
449
- if (!entry) return;
450
-
451
- if (Array.isArray(entry)) {
452
- const updated = entry.filter((_value, idx) => idx !== index);
453
- if (updated.length > 0) {
454
- this.data[provider] = updated;
455
- } else {
456
- delete this.data[provider];
457
- }
458
- } else {
459
- delete this.data[provider];
460
- }
461
-
365
+ const entries = this.getStoredCredentials(provider);
366
+ if (index < 0 || index >= entries.length) return;
367
+ this.storage.deleteAuthCredential(entries[index].id);
368
+ const updated = entries.filter((_value, idx) => idx !== index);
369
+ this.setStoredCredentials(provider, updated);
462
370
  this.resetProviderAssignments(provider);
463
371
  }
464
372
 
@@ -473,29 +381,33 @@ export class AuthStorage {
473
381
  * Set credential for a provider.
474
382
  */
475
383
  async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
476
- this.data[provider] = credential;
384
+ const normalized = Array.isArray(credential) ? credential : [credential];
385
+ const stored = this.storage.replaceAuthCredentialsForProvider(provider, normalized);
386
+ this.setStoredCredentials(
387
+ provider,
388
+ stored.map((record) => ({ id: record.id, credential: record.credential })),
389
+ );
477
390
  this.resetProviderAssignments(provider);
478
- await this.save();
479
391
  }
480
392
 
481
393
  /**
482
394
  * Remove credential for a provider.
483
395
  */
484
396
  async remove(provider: string): Promise<void> {
485
- delete this.data[provider];
397
+ this.storage.deleteAuthCredentialsForProvider(provider);
398
+ this.data.delete(provider);
486
399
  this.resetProviderAssignments(provider);
487
- await this.save();
488
400
  }
489
401
 
490
402
  /**
491
403
  * List all providers with credentials.
492
404
  */
493
405
  list(): string[] {
494
- return Object.keys(this.data);
406
+ return [...this.data.keys()];
495
407
  }
496
408
 
497
409
  /**
498
- * Check if credentials exist for a provider in auth.json.
410
+ * Check if credentials exist for a provider in agent.db.
499
411
  */
500
412
  has(provider: string): boolean {
501
413
  return this.getCredentialsForProvider(provider).length > 0;
@@ -533,7 +445,16 @@ export class AuthStorage {
533
445
  * Get all credentials.
534
446
  */
535
447
  getAll(): AuthStorageData {
536
- return { ...this.data };
448
+ const result: AuthStorageData = {};
449
+ for (const [provider, entries] of this.data.entries()) {
450
+ const credentials = entries.map((entry) => entry.credential);
451
+ if (credentials.length === 1) {
452
+ result[provider] = credentials[0];
453
+ } else if (credentials.length > 1) {
454
+ result[provider] = credentials;
455
+ }
456
+ }
457
+ return result;
537
458
  }
538
459
 
539
460
  /**
@@ -887,7 +808,6 @@ export class AuthStorage {
887
808
 
888
809
  const updated: OAuthCredential = { type: "oauth", ...result.newCredentials };
889
810
  this.replaceCredentialAt(provider, selection.index, updated);
890
- await this.save();
891
811
 
892
812
  if (checkUsage) {
893
813
  const usage = await this.getCodexUsage(updated, options?.baseUrl);
@@ -906,7 +826,6 @@ export class AuthStorage {
906
826
  return result.apiKey;
907
827
  } catch {
908
828
  this.removeCredentialAt(provider, selection.index);
909
- await this.save();
910
829
  if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
911
830
  return this.getApiKey(provider, sessionId, options);
912
831
  }
@@ -919,8 +838,8 @@ export class AuthStorage {
919
838
  * Get API key for a provider.
920
839
  * Priority:
921
840
  * 1. Runtime override (CLI --api-key)
922
- * 2. API key from auth.json
923
- * 3. OAuth token from auth.json (auto-refreshed)
841
+ * 2. API key from agent.db
842
+ * 3. OAuth token from agent.db (auto-refreshed)
924
843
  * 4. Environment variable
925
844
  * 5. Fallback resolver (models.json custom providers)
926
845
  */
@@ -8,14 +8,15 @@
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
9
  import type { Model } from "@oh-my-pi/pi-ai";
10
10
  import { completeSimple } from "@oh-my-pi/pi-ai";
11
- import branchSummaryPrompt from "../../prompts/branch-summary.md" with { type: "text" };
12
- import branchSummaryPreamble from "../../prompts/branch-summary-preamble.md" with { type: "text" };
11
+ import branchSummaryPrompt from "../../prompts/compaction/branch-summary.md" with { type: "text" };
12
+ import branchSummaryPreamble from "../../prompts/compaction/branch-summary-preamble.md" with { type: "text" };
13
13
  import {
14
14
  convertToLlm,
15
15
  createBranchSummaryMessage,
16
16
  createCompactionSummaryMessage,
17
17
  createCustomMessage,
18
18
  } from "../messages";
19
+ import { renderPromptTemplate } from "../prompt-templates";
19
20
  import type { ReadonlySessionManager, SessionEntry } from "../session-manager";
20
21
  import { estimateTokens } from "./compaction";
21
22
  import {
@@ -237,9 +238,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
237
238
  // Summary Generation
238
239
  // ============================================================================
239
240
 
240
- const BRANCH_SUMMARY_PREAMBLE = branchSummaryPreamble;
241
+ const BRANCH_SUMMARY_PREAMBLE = renderPromptTemplate(branchSummaryPreamble);
241
242
 
242
- const BRANCH_SUMMARY_PROMPT = branchSummaryPrompt;
243
+ const BRANCH_SUMMARY_PROMPT = renderPromptTemplate(branchSummaryPrompt);
243
244
 
244
245
  /**
245
246
  * Generate a summary of abandoned branch entries.
@@ -8,10 +8,11 @@
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
9
  import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
10
10
  import { complete, completeSimple } from "@oh-my-pi/pi-ai";
11
- import compactionSummaryPrompt from "../../prompts/compaction-summary.md" with { type: "text" };
12
- import compactionTurnPrefixPrompt from "../../prompts/compaction-turn-prefix.md" with { type: "text" };
13
- import compactionUpdateSummaryPrompt from "../../prompts/compaction-update-summary.md" with { type: "text" };
11
+ import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" };
12
+ import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" };
13
+ import compactionUpdateSummaryPrompt from "../../prompts/compaction/compaction-update-summary.md" with { type: "text" };
14
14
  import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages";
15
+ import { renderPromptTemplate } from "../prompt-templates";
15
16
  import type { CompactionEntry, SessionEntry } from "../session-manager";
16
17
  import {
17
18
  computeFileLists,
@@ -386,9 +387,9 @@ export function findCutPoint(
386
387
  // Summarization
387
388
  // ============================================================================
388
389
 
389
- const SUMMARIZATION_PROMPT = compactionSummaryPrompt;
390
+ const SUMMARIZATION_PROMPT = renderPromptTemplate(compactionSummaryPrompt);
390
391
 
391
- const UPDATE_SUMMARIZATION_PROMPT = compactionUpdateSummaryPrompt;
392
+ const UPDATE_SUMMARIZATION_PROMPT = renderPromptTemplate(compactionUpdateSummaryPrompt);
392
393
 
393
394
  /**
394
395
  * Generate a summary of the conversation using the LLM.
@@ -552,7 +553,7 @@ export function prepareCompaction(
552
553
  // Main compaction function
553
554
  // ============================================================================
554
555
 
555
- const TURN_PREFIX_SUMMARIZATION_PROMPT = compactionTurnPrefixPrompt;
556
+ const TURN_PREFIX_SUMMARIZATION_PROMPT = renderPromptTemplate(compactionTurnPrefixPrompt);
556
557
 
557
558
  /**
558
559
  * Generate summaries for compaction using prepared data.
@@ -4,7 +4,9 @@
4
4
 
5
5
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
6
6
  import type { Message } from "@oh-my-pi/pi-ai";
7
- import summarizationSystemPrompt from "../../prompts/summarization-system.md" with { type: "text" };
7
+ import fileOperationsTemplate from "../../prompts/system/file-operations.md" with { type: "text" };
8
+ import summarizationSystemPrompt from "../../prompts/system/summarization-system.md" with { type: "text" };
9
+ import { renderPromptTemplate } from "../prompt-templates";
8
10
 
9
11
  // ============================================================================
10
12
  // File Operation Tracking
@@ -71,15 +73,8 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
71
73
  * Format file operations as XML tags for summary.
72
74
  */
73
75
  export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
74
- const sections: string[] = [];
75
- if (readFiles.length > 0) {
76
- sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
77
- }
78
- if (modifiedFiles.length > 0) {
79
- sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
80
- }
81
- if (sections.length === 0) return "";
82
- return `\n\n${sections.join("\n\n")}`;
76
+ if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
77
+ return renderPromptTemplate(fileOperationsTemplate, { readFiles, modifiedFiles });
83
78
  }
84
79
 
85
80
  // ============================================================================
@@ -150,4 +145,4 @@ export function serializeConversation(messages: Message[]): string {
150
145
  // Summarization System Prompt
151
146
  // ============================================================================
152
147
 
153
- export const SUMMARIZATION_SYSTEM_PROMPT = summarizationSystemPrompt;
148
+ export const SUMMARIZATION_SYSTEM_PROMPT = renderPromptTemplate(summarizationSystemPrompt);
@@ -14,6 +14,7 @@
14
14
 
15
15
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
16
16
  import type { HookCommandContext } from "../../../hooks/types";
17
+ import { renderPromptTemplate } from "../../../prompt-templates";
17
18
  import type { CustomCommand, CustomCommandAPI } from "../../types";
18
19
 
19
20
  // ─────────────────────────────────────────────────────────────────────────────
@@ -164,20 +165,6 @@ function getRecommendedAgentCount(stats: DiffStats): number {
164
165
  return Math.min(16, fileCount);
165
166
  }
166
167
 
167
- /**
168
- * Format diff stats as a markdown table for the prompt.
169
- */
170
- function formatFileTable(files: FileDiff[]): string {
171
- if (files.length === 0) return "_No files to review._";
172
-
173
- const rows = files.map((f) => {
174
- const ext = getFileExt(f.path);
175
- return `| ${f.path} | +${f.linesAdded}/-${f.linesRemoved} | ${ext} |`;
176
- });
177
-
178
- return `| File | +/- | Type |\n|------|-----|------|\n${rows.join("\n")}`;
179
- }
180
-
181
168
  /**
182
169
  * Extract first N lines of actual diff content (excluding headers) for preview.
183
170
  */
@@ -203,33 +190,6 @@ function getDiffPreview(hunks: string, maxLines: number): string {
203
190
  return contentLines.join("\n");
204
191
  }
205
192
 
206
- /**
207
- * Format condensed diff previews for large changesets.
208
- */
209
- function formatDiffPreviews(files: FileDiff[], linesPerFile: number): string {
210
- const parts: string[] = [];
211
-
212
- for (const f of files) {
213
- const preview = getDiffPreview(f.hunks, linesPerFile);
214
- if (preview.trim()) {
215
- parts.push(`#### ${f.path}\n\`\`\`diff\n${preview}\n\`\`\``);
216
- }
217
- }
218
-
219
- return parts.join("\n\n");
220
- }
221
-
222
- /**
223
- * Format excluded files list for the prompt.
224
- */
225
- function formatExcluded(excluded: DiffStats["excluded"]): string {
226
- if (excluded.length === 0) return "";
227
-
228
- const items = excluded.map((e) => `- \`${e.path}\` (+${e.linesAdded}/-${e.linesRemoved}) — ${e.reason}`);
229
-
230
- return `### Excluded Files (${excluded.length})\n\n${items.join("\n")}`;
231
- }
232
-
233
193
  // Thresholds for diff inclusion
234
194
  const MAX_DIFF_CHARS = 50_000; // Don't include diff above this
235
195
  const MAX_FILES_FOR_INLINE_DIFF = 20; // Don't include diff if more files than this
@@ -241,59 +201,27 @@ function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string): str
241
201
  const agentCount = getRecommendedAgentCount(stats);
242
202
  const skipDiff = rawDiff.length > MAX_DIFF_CHARS || stats.files.length > MAX_FILES_FOR_INLINE_DIFF;
243
203
  const totalLines = stats.totalAdded + stats.totalRemoved;
244
-
245
- // Build distribution guidance
246
- const distributionGuidance =
247
- `Based on the diff weight (~${totalLines} lines across ${stats.files.length} files), ` +
248
- (agentCount === 1 ? `use **1 reviewer agent**.` : `spawn **${agentCount} reviewer agents** in parallel.`);
249
-
250
- // Build grouping guidance (only for multi-agent)
251
- const groupingGuidance =
252
- agentCount > 1
253
- ? `Group files by locality (related changes together). For example:
254
- - Files in the same directory or module → same agent
255
- - Files that implement related functionality → same agent
256
- - Test files with their implementation files → same agent
257
-
258
- Use the Task tool with \`agent: "reviewer"\` and the batch \`tasks\` array to run reviews in parallel.`
259
- : "";
260
-
261
- // Build diff section
262
- let diffSection: string;
263
- if (!skipDiff) {
264
- diffSection = `### Diff
265
-
266
- <diff>
267
- ${rawDiff.trim()}
268
- </diff>`;
269
- } else {
270
- const linesPerFile = Math.max(5, Math.floor(100 / stats.files.length));
271
- diffSection = `### Diff Previews
272
-
273
- _Full diff too large (${stats.files.length} files). Showing first ~${linesPerFile} lines per file. Reviewers should fetch full diffs for assigned files._
274
-
275
- ${formatDiffPreviews(stats.files, linesPerFile)}`;
276
- }
277
-
278
- // Build diff instruction
279
- const diffInstruction = skipDiff
280
- ? "Run `git diff` or `git show` to get the diff for assigned files"
281
- : "Use the diff hunks provided below (don't re-run git diff)";
282
-
283
- // Replace template variables
284
- return reviewRequestTemplate
285
- .replace("{MODE}", mode)
286
- .replace("{FILE_COUNT}", String(stats.files.length))
287
- .replace("{LINES_ADDED}", String(stats.totalAdded))
288
- .replace("{LINES_REMOVED}", String(stats.totalRemoved))
289
- .replace("{FILE_TABLE}", formatFileTable(stats.files))
290
- .replace("{EXCLUDED_SECTION}", stats.excluded.length > 0 ? formatExcluded(stats.excluded) : "")
291
- .replace("{DISTRIBUTION_GUIDANCE}", distributionGuidance)
292
- .replace("{GROUPING_GUIDANCE}", groupingGuidance)
293
- .replace("{DIFF_INSTRUCTION}", diffInstruction)
294
- .replace("{DIFF_SECTION}", diffSection)
295
- .replace(/\n{3,}/g, "\n\n") // Collapse multiple blank lines
296
- .trim();
204
+ const linesPerFile = skipDiff ? Math.max(5, Math.floor(100 / stats.files.length)) : 0;
205
+
206
+ const filesWithExt = stats.files.map((f) => ({
207
+ ...f,
208
+ ext: getFileExt(f.path),
209
+ hunksPreview: skipDiff ? getDiffPreview(f.hunks, linesPerFile) : "",
210
+ }));
211
+
212
+ return renderPromptTemplate(reviewRequestTemplate, {
213
+ mode,
214
+ files: filesWithExt,
215
+ excluded: stats.excluded,
216
+ totalAdded: stats.totalAdded,
217
+ totalRemoved: stats.totalRemoved,
218
+ totalLines,
219
+ agentCount,
220
+ multiAgent: agentCount > 1,
221
+ skipDiff,
222
+ rawDiff: rawDiff.trim(),
223
+ linesPerFile,
224
+ });
297
225
  }
298
226
 
299
227
  export function createReviewCommand(api: CustomCommandAPI): CustomCommand {