@oh-my-pi/pi-coding-agent 4.0.1 → 4.2.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 (85) hide show
  1. package/CHANGELOG.md +49 -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-storage.ts +450 -0
  7. package/src/core/auth-storage.ts +111 -184
  8. package/src/core/compaction/branch-summarization.ts +5 -4
  9. package/src/core/compaction/compaction.ts +7 -6
  10. package/src/core/compaction/utils.ts +6 -11
  11. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  12. package/src/core/custom-share.ts +66 -0
  13. package/src/core/history-storage.ts +174 -0
  14. package/src/core/index.ts +1 -0
  15. package/src/core/keybindings.ts +3 -0
  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 +87 -289
  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/edit.ts +2 -1
  27. package/src/core/tools/find.ts +2 -1
  28. package/src/core/tools/gemini-image.ts +2 -1
  29. package/src/core/tools/git.ts +2 -2
  30. package/src/core/tools/grep.ts +2 -1
  31. package/src/core/tools/index.test.ts +0 -28
  32. package/src/core/tools/index.ts +0 -6
  33. package/src/core/tools/lsp/index.ts +2 -1
  34. package/src/core/tools/output.ts +2 -1
  35. package/src/core/tools/read.ts +4 -1
  36. package/src/core/tools/ssh.ts +4 -2
  37. package/src/core/tools/task/agents.ts +56 -30
  38. package/src/core/tools/task/commands.ts +9 -8
  39. package/src/core/tools/task/index.ts +7 -15
  40. package/src/core/tools/web-fetch.ts +2 -1
  41. package/src/core/tools/web-search/auth.ts +106 -16
  42. package/src/core/tools/web-search/index.ts +3 -2
  43. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  44. package/src/core/tools/write.ts +2 -1
  45. package/src/core/voice.ts +3 -1
  46. package/src/main.ts +1 -1
  47. package/src/migrations.ts +20 -20
  48. package/src/modes/interactive/components/custom-editor.ts +7 -0
  49. package/src/modes/interactive/components/history-search.ts +158 -0
  50. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  51. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  52. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  53. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  54. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  55. package/src/modes/interactive/interactive-mode.ts +370 -3115
  56. package/src/modes/interactive/theme/theme.ts +5 -5
  57. package/src/modes/interactive/types.ts +189 -0
  58. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  59. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  60. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  61. package/src/prompts/agents/frontmatter.md +7 -0
  62. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  63. package/src/prompts/{task.md → agents/task.md} +1 -1
  64. package/src/prompts/review-request.md +44 -8
  65. package/src/prompts/system/custom-system-prompt.md +80 -0
  66. package/src/prompts/system/file-operations.md +12 -0
  67. package/src/prompts/system/system-prompt.md +232 -0
  68. package/src/prompts/system/title-system.md +2 -0
  69. package/src/prompts/tools/bash.md +1 -1
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/prompts/tools/task.md +9 -3
  72. package/src/core/tools/rulebook.ts +0 -132
  73. package/src/prompts/system-prompt.md +0 -43
  74. package/src/prompts/title-system.md +0 -8
  75. /package/src/prompts/{architect-plan.md → agents/architect-plan.md} +0 -0
  76. /package/src/prompts/{implement-with-critic.md → agents/implement-with-critic.md} +0 -0
  77. /package/src/prompts/{implement.md → agents/implement.md} +0 -0
  78. /package/src/prompts/{init.md → agents/init.md} +0 -0
  79. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  80. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  81. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  82. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  83. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  84. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  85. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Migrates legacy JSON storage (settings.json, auth.json) to SQLite-based agent.db.
3
+ * Settings migrate only when the DB has no settings; auth merges per-provider when missing.
4
+ * Original JSON files are backed up to .bak and removed after successful migration.
5
+ */
6
+
7
+ import { getAgentDbPath } from "../config";
8
+ import { AgentStorage } from "./agent-storage";
9
+ import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "./auth-storage";
10
+ import { logger } from "./logger";
11
+ import type { Settings } from "./settings-manager";
12
+
13
+ /** Paths configuration for the storage migration process. */
14
+ type MigrationPaths = {
15
+ /** Directory containing agent.db */
16
+ agentDir: string;
17
+ /** Path to legacy settings.json file */
18
+ settingsPath: string;
19
+ /** Candidate paths to search for auth.json (checked in order) */
20
+ authPaths: string[];
21
+ };
22
+
23
+ /** Result of the JSON-to-SQLite storage migration. */
24
+ export interface StorageMigrationResult {
25
+ /** Whether settings.json was migrated to agent.db */
26
+ migratedSettings: boolean;
27
+ /** Whether auth.json was migrated to agent.db */
28
+ migratedAuth: boolean;
29
+ /** Non-fatal issues encountered during migration */
30
+ warnings: string[];
31
+ }
32
+
33
+ /**
34
+ * Type guard for plain objects.
35
+ * @param value - Value to check
36
+ * @returns True if value is a non-null, non-array object
37
+ */
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return !!value && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ /**
43
+ * Transforms legacy settings to current schema (e.g., queueMode -> steeringMode).
44
+ * @param settings - Settings object potentially containing deprecated keys
45
+ * @returns Settings with deprecated keys renamed to current equivalents
46
+ */
47
+ function migrateLegacySettings(settings: Settings): Settings {
48
+ const migrated = { ...settings } as Record<string, unknown>;
49
+ if ("queueMode" in migrated && !("steeringMode" in migrated)) {
50
+ migrated.steeringMode = migrated.queueMode;
51
+ delete migrated.queueMode;
52
+ }
53
+ return migrated as Settings;
54
+ }
55
+
56
+ /**
57
+ * Normalizes credential entries to array format (legacy stored single credentials).
58
+ * @param entry - Single credential or array of credentials
59
+ * @returns Array of credentials (empty if entry is undefined)
60
+ */
61
+ function normalizeCredentialEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
62
+ if (!entry) return [];
63
+ return Array.isArray(entry) ? entry : [entry];
64
+ }
65
+
66
+ /**
67
+ * Reads and parses a JSON file.
68
+ * @param path - Path to the JSON file
69
+ * @returns Parsed JSON content, or null if file doesn't exist or parsing fails
70
+ */
71
+ async function readJsonFile<T>(path: string): Promise<T | null> {
72
+ try {
73
+ const file = Bun.file(path);
74
+ if (!(await file.exists())) return null;
75
+ const content = await file.text();
76
+ return JSON.parse(content) as T;
77
+ } catch (error) {
78
+ logger.warn("Storage migration failed to read JSON", { path, error: String(error) });
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Backs up a JSON file to .bak and removes the original.
85
+ * Prevents re-migration on subsequent runs.
86
+ * @param path - Path to the JSON file to backup
87
+ */
88
+ async function backupJson(path: string): Promise<void> {
89
+ const file = Bun.file(path);
90
+ if (!(await file.exists())) return;
91
+
92
+ const backupPath = `${path}.bak`;
93
+ try {
94
+ const content = await file.arrayBuffer();
95
+ await Bun.write(backupPath, content);
96
+ await file.unlink();
97
+ } catch (error) {
98
+ logger.warn("Storage migration failed to backup JSON", { path, error: String(error) });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Migrates settings.json to SQLite storage if DB is empty.
104
+ * @param storage - AgentStorage instance to migrate into
105
+ * @param settingsPath - Path to legacy settings.json
106
+ * @param warnings - Array to collect non-fatal warnings
107
+ * @returns True if migration was performed
108
+ */
109
+ async function migrateSettings(storage: AgentStorage, settingsPath: string, warnings: string[]): Promise<boolean> {
110
+ const settingsFile = Bun.file(settingsPath);
111
+ const settingsExists = await settingsFile.exists();
112
+ const hasDbSettings = storage.getSettings() !== null;
113
+
114
+ if (!settingsExists) return false;
115
+ if (hasDbSettings) {
116
+ warnings.push(`settings.json exists but agent.db is authoritative: ${settingsPath}`);
117
+ return false;
118
+ }
119
+
120
+ const settingsJson = await readJsonFile<Settings>(settingsPath);
121
+ if (!settingsJson) return false;
122
+
123
+ storage.saveSettings(migrateLegacySettings(settingsJson));
124
+ await backupJson(settingsPath);
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Finds the first valid auth.json from candidate paths (checked in priority order).
130
+ * @param authPaths - Candidate paths to search (e.g., project-local before global)
131
+ * @returns First valid auth file with its path and parsed data, or null if none found
132
+ */
133
+ async function findFirstAuthJson(authPaths: string[]): Promise<{ path: string; data: AuthStorageData } | null> {
134
+ for (const authPath of authPaths) {
135
+ const data = await readJsonFile<AuthStorageData>(authPath);
136
+ if (data && isRecord(data)) {
137
+ return { path: authPath, data };
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Validates that a credential has a recognized type.
145
+ * @param entry - Credential to validate
146
+ * @returns True if credential type is api_key or oauth
147
+ */
148
+ function isValidCredential(entry: AuthCredential): boolean {
149
+ return entry.type === "api_key" || entry.type === "oauth";
150
+ }
151
+
152
+ /**
153
+ * Migrates auth.json to SQLite storage for providers missing in agent.db.
154
+ * @param storage - AgentStorage instance to migrate into
155
+ * @param authPaths - Candidate paths to search for auth.json
156
+ * @param warnings - Array to collect non-fatal warnings
157
+ * @returns True if migration was performed
158
+ */
159
+ async function migrateAuth(storage: AgentStorage, authPaths: string[], warnings: string[]): Promise<boolean> {
160
+ const authJson = await findFirstAuthJson(authPaths);
161
+ if (!authJson) return false;
162
+
163
+ let sawValid = false;
164
+ let migratedAny = false;
165
+
166
+ for (const [provider, entry] of Object.entries(authJson.data)) {
167
+ const credentials = normalizeCredentialEntry(entry)
168
+ .filter(isValidCredential)
169
+ .map((credential) => credential);
170
+
171
+ if (credentials.length === 0) continue;
172
+ sawValid = true;
173
+
174
+ if (storage.listAuthCredentials(provider).length > 0) {
175
+ continue;
176
+ }
177
+
178
+ storage.replaceAuthCredentialsForProvider(provider, credentials);
179
+ migratedAny = true;
180
+ }
181
+
182
+ if (sawValid) {
183
+ await backupJson(authJson.path);
184
+ }
185
+
186
+ if (!migratedAny && sawValid) {
187
+ warnings.push(`auth.json entries already present in agent.db: ${authJson.path}`);
188
+ }
189
+
190
+ return migratedAny;
191
+ }
192
+
193
+ /**
194
+ * Migrates legacy JSON files (settings.json, auth.json) to SQLite-based agent.db.
195
+ * Settings migrate only when the DB has no settings; auth merges per-provider when missing.
196
+ * @param paths - Configuration specifying locations of legacy files and target DB
197
+ * @returns Result indicating what was migrated and any warnings encountered
198
+ */
199
+ export async function migrateJsonStorage(paths: MigrationPaths): Promise<StorageMigrationResult> {
200
+ const storage = AgentStorage.open(getAgentDbPath(paths.agentDir));
201
+ const warnings: string[] = [];
202
+
203
+ const [migratedSettings, migratedAuth] = await Promise.all([
204
+ migrateSettings(storage, paths.settingsPath, warnings),
205
+ migrateAuth(storage, paths.authPaths, warnings),
206
+ ]);
207
+
208
+ if (warnings.length > 0) {
209
+ for (const warning of warnings) {
210
+ logger.warn("Storage migration warning", { warning });
211
+ }
212
+ }
213
+
214
+ return { migratedSettings, migratedAuth, warnings };
215
+ }
@@ -7,14 +7,14 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import chalk from "chalk";
9
9
  import { contextFileCapability } from "../capability/context-file";
10
- import type { Rule } from "../capability/rule";
11
10
  import { systemPromptCapability } from "../capability/system-prompt";
12
11
  import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
13
- import systemPromptTemplate from "../prompts/system-prompt.md" with { type: "text" };
12
+ import customSystemPromptTemplate from "../prompts/system/custom-system-prompt.md" with { type: "text" };
13
+ import systemPromptTemplate from "../prompts/system/system-prompt.md" with { type: "text" };
14
+ import { renderPromptTemplate } from "./prompt-templates";
14
15
  import type { SkillsSettings } from "./settings-manager";
15
- import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills";
16
+ import { loadSkills, type Skill } from "./skills";
16
17
  import type { ToolName } from "./tools/index";
17
- import { formatRulesForPrompt } from "./tools/rulebook";
18
18
 
19
19
  /**
20
20
  * Execute a git command synchronously and return stdout or null on failure.
@@ -25,11 +25,19 @@ function execGit(args: string[], cwd: string): string | null {
25
25
  return result.stdout.toString().trim() || null;
26
26
  }
27
27
 
28
+ interface GitContext {
29
+ isRepo: boolean;
30
+ currentBranch: string;
31
+ mainBranch: string;
32
+ status: string;
33
+ commits: string;
34
+ }
35
+
28
36
  /**
29
37
  * Load git context for the system prompt.
30
- * Returns formatted git status or null if not in a git repo.
38
+ * Returns structured git data or null if not in a git repo.
31
39
  */
32
- export function loadGitContext(cwd: string): string | null {
40
+ export function loadGitContext(cwd: string): GitContext | null {
33
41
  // Check if inside a git repo
34
42
  const isGitRepo = execGit(["rev-parse", "--is-inside-work-tree"], cwd);
35
43
  if (isGitRepo !== "true") return null;
@@ -48,22 +56,19 @@ export function loadGitContext(cwd: string): string | null {
48
56
 
49
57
  // Get git status (porcelain format for parsing)
50
58
  const gitStatus = execGit(["status", "--porcelain"], cwd);
51
- const statusText = gitStatus?.trim() || "(clean)";
59
+ const status = gitStatus?.trim() || "(clean)";
52
60
 
53
61
  // Get recent commits
54
62
  const recentCommits = execGit(["log", "--oneline", "-5"], cwd);
55
- const commitsText = recentCommits?.trim() || "(no commits)";
56
-
57
- return `This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
58
- Current branch: ${currentBranch}
59
-
60
- Main branch (you will usually use this for PRs): ${mainBranch}
61
-
62
- Status:
63
- ${statusText}
63
+ const commits = recentCommits?.trim() || "(no commits)";
64
64
 
65
- Recent commits:
66
- ${commitsText}`;
65
+ return {
66
+ isRepo: true,
67
+ currentBranch,
68
+ mainBranch,
69
+ status,
70
+ commits,
71
+ };
67
72
  }
68
73
 
69
74
  /** Tool descriptions for system prompt */
@@ -88,47 +93,6 @@ const toolDescriptions: Record<ToolName, string> = {
88
93
  report_finding: "Report a finding during code review",
89
94
  };
90
95
 
91
- function applyTemplate(template: string, values: Record<string, string>): string {
92
- let output = template;
93
- for (const [key, value] of Object.entries(values)) {
94
- output = output.replaceAll(`{{${key}}}`, value);
95
- }
96
- return output;
97
- }
98
-
99
- function appendBlock(prompt: string, block: string | null | undefined, separator = "\n\n"): string {
100
- if (!block) return prompt;
101
- if (block.startsWith("\n")) {
102
- return `${prompt}${block}`;
103
- }
104
- return `${prompt}${separator}${block}`;
105
- }
106
-
107
- function appendSection(prompt: string, title: string, content: string | null | undefined): string {
108
- if (!content) return prompt;
109
- return `${prompt}\n\n# ${title}\n\n${content}`;
110
- }
111
-
112
- function formatProjectContext(contextFiles: Array<{ path: string; content: string; depth?: number }>): string | null {
113
- if (contextFiles.length === 0) return null;
114
- const parts: string[] = ["The following project context files have been loaded:", ""];
115
- for (const { path: filePath, content } of contextFiles) {
116
- parts.push(`## ${filePath}`, "", content, "");
117
- }
118
- return parts.join("\n").trimEnd();
119
- }
120
-
121
- function formatToolDescriptions(tools: Map<string, { description: string; label: string }> | undefined): string | null {
122
- if (!tools || tools.size === 0) return null;
123
- return Array.from(tools.entries())
124
- .map(([name, { description }]) => `- ${name}: ${description}`)
125
- .join("\n");
126
- }
127
-
128
- function buildPromptFooter(dateTime: string, cwd: string): string {
129
- return `Current date and time: ${dateTime}\nCurrent working directory: ${cwd}`;
130
- }
131
-
132
96
  function execCommand(args: string[]): string | null {
133
97
  const result = Bun.spawnSync(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
134
98
  if (result.exitCode !== 0) return null;
@@ -504,7 +468,7 @@ function getDiskInfo(): string | null {
504
468
  }
505
469
  }
506
470
 
507
- function formatEnvironmentInfo(): string {
471
+ function getEnvironmentInfo(): Array<{ label: string; value: string }> {
508
472
  // Load cached system info or collect fresh
509
473
  let sysInfo = loadSystemInfoCache();
510
474
  if (!sysInfo) {
@@ -512,127 +476,19 @@ function formatEnvironmentInfo(): string {
512
476
  saveSystemInfoCache(sysInfo);
513
477
  }
514
478
 
515
- // Session-specific values (not cached)
516
- const items: Array<[string, string]> = [
517
- ["OS", sysInfo.os],
518
- ["Distro", sysInfo.distro],
519
- ["Kernel", sysInfo.kernel],
520
- ["Arch", sysInfo.arch],
521
- ["CPU", sysInfo.cpu],
522
- ["GPU", sysInfo.gpu],
523
- ["Disk", sysInfo.disk],
524
- ["Shell", getShellName()],
525
- ["Terminal", getTerminalName()],
526
- ["DE", getDesktopEnvironment()],
527
- ["WM", getWindowManager()],
479
+ return [
480
+ { label: "OS", value: sysInfo.os },
481
+ { label: "Distro", value: sysInfo.distro },
482
+ { label: "Kernel", value: sysInfo.kernel },
483
+ { label: "Arch", value: sysInfo.arch },
484
+ { label: "CPU", value: sysInfo.cpu },
485
+ { label: "GPU", value: sysInfo.gpu },
486
+ { label: "Disk", value: sysInfo.disk },
487
+ { label: "Shell", value: getShellName() },
488
+ { label: "Terminal", value: getTerminalName() },
489
+ { label: "DE", value: getDesktopEnvironment() },
490
+ { label: "WM", value: getWindowManager() },
528
491
  ];
529
- return items.map(([label, value]) => `- ${label}: ${value}`).join("\n");
530
- }
531
-
532
- /**
533
- * Generate anti-bash rules section if the agent has both bash and specialized tools.
534
- * Only include rules for tools that are actually available.
535
- */
536
- function generateAntiBashRules(tools: ToolName[]): string | null {
537
- const hasBash = tools.includes("bash");
538
- if (!hasBash) return null;
539
-
540
- const hasRead = tools.includes("read");
541
- const hasGrep = tools.includes("grep");
542
- const hasFind = tools.includes("find");
543
- const hasLs = tools.includes("ls");
544
- const hasEdit = tools.includes("edit");
545
- const hasLsp = tools.includes("lsp");
546
- const hasGit = tools.includes("git");
547
-
548
- // Only show rules if we have specialized tools that should be preferred
549
- const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit || hasGit;
550
- if (!hasSpecializedTools) return null;
551
-
552
- const lines: string[] = [];
553
- lines.push("## Tool Usage Rules — MANDATORY\n");
554
- lines.push("### Forbidden Bash Patterns");
555
- lines.push("NEVER use bash for these operations:\n");
556
-
557
- if (hasRead) lines.push("- **File reading**: Use `read` instead of cat/head/tail/less/more");
558
- if (hasGrep) lines.push("- **Content search**: Use `grep` instead of grep/rg/ag/ack");
559
- if (hasFind) lines.push("- **File finding**: Use `find` instead of find/fd/locate");
560
- if (hasLs) lines.push("- **Directory listing**: Use `ls` instead of bash ls");
561
- if (hasEdit) lines.push("- **File editing**: Use `edit` instead of sed/awk/perl -pi/echo >/cat <<EOF");
562
- if (hasGit) lines.push("- **Git operations**: Use `git` tool instead of bash git commands");
563
-
564
- lines.push("\n### Tool Preference (highest → lowest priority)");
565
- const ladder: string[] = [];
566
- if (hasLsp) ladder.push("lsp (go-to-definition, references, type info) — DETERMINISTIC");
567
- if (hasGrep) ladder.push("grep (text/regex search)");
568
- if (hasFind) ladder.push("find (locate files by pattern)");
569
- if (hasRead) ladder.push("read (view file contents)");
570
- if (hasEdit) ladder.push("edit (precise text replacement)");
571
- if (hasGit) ladder.push("git (structured git operations with safety guards)");
572
- ladder.push(`bash (ONLY for ${hasGit ? "" : "git, "}npm, docker, make, cargo, etc.)`);
573
- lines.push(ladder.map((t, i) => `${i + 1}. ${t}`).join("\n"));
574
-
575
- // Add LSP guidance if available
576
- if (hasLsp) {
577
- lines.push("\n### LSP — Preferred for Semantic Queries");
578
- lines.push("Use `lsp` instead of grep/bash when you need:");
579
- lines.push("- **Where is X defined?** → `lsp definition`");
580
- lines.push("- **What calls X?** → `lsp incoming_calls`");
581
- lines.push("- **What does X call?** → `lsp outgoing_calls`");
582
- lines.push("- **What type is X?** → `lsp hover`");
583
- lines.push("- **What symbols are in this file?** → `lsp symbols`");
584
- lines.push("- **Find symbol across codebase** → `lsp workspace_symbols`\n");
585
- }
586
-
587
- // Add Git guidance if available
588
- if (hasGit) {
589
- lines.push("\n### Git Tool — Preferred for Git Operations");
590
- lines.push("Use `git` instead of bash git when you need:");
591
- lines.push(
592
- "- **Status/diff/log**: `git { operation: 'status' }`, `git { operation: 'diff' }`, `git { operation: 'log' }`",
593
- );
594
- lines.push(
595
- "- **Commit workflow**: `git { operation: 'add', paths: [...] }` then `git { operation: 'commit', message: '...' }`",
596
- );
597
- lines.push("- **Branching**: `git { operation: 'branch', action: 'create', name: '...' }`");
598
- lines.push("- **GitHub PRs**: `git { operation: 'pr', action: 'create', title: '...', body: '...' }`");
599
- lines.push(
600
- "- **GitHub Issues**: `git { operation: 'issue', action: 'list' }` or `{ operation: 'issue', number: 123 }`",
601
- );
602
- lines.push(
603
- "The git tool provides typed output, safety guards, and a clean API for all git and GitHub operations.\n",
604
- );
605
- }
606
-
607
- // Add SSH remote filesystem guidance if available
608
- const hasSSH = tools.includes("ssh");
609
- if (hasSSH) {
610
- lines.push("\n### SSH Command Execution");
611
- lines.push(
612
- "**Critical**: Each SSH host runs a specific shell. **You MUST match commands to the host's shell type**.",
613
- );
614
- lines.push("Check the host list in the ssh tool description. Shell types:");
615
- lines.push("- linux/bash, linux/zsh, macos/bash, macos/zsh: ls, cat, grep, find, ps, df, uname");
616
- lines.push("- windows/bash, windows/sh: ls, cat, grep, find (Windows with WSL/Cygwin — Unix commands)");
617
- lines.push("- windows/cmd: dir, type, findstr, tasklist, systeminfo");
618
- lines.push("- windows/powershell: Get-ChildItem, Get-Content, Select-String, Get-Process");
619
- lines.push("");
620
- lines.push("### SSH Filesystems");
621
- lines.push("Mounted at `~/.omp/remote/<hostname>/` — use read/edit/write tools directly.");
622
- lines.push("Windows paths need colon: `~/.omp/remote/host/C:/Users/...` not `C/Users/...`\n");
623
- }
624
-
625
- // Add search-first protocol
626
- if (hasGrep || hasFind) {
627
- lines.push("\n### Search-First Protocol");
628
- lines.push("Before reading any file:");
629
- if (hasFind) lines.push("1. Unknown structure → `find` to see file layout");
630
- if (hasGrep) lines.push("2. Known location → `grep` for specific symbol/error");
631
- if (hasRead) lines.push("3. Use `read offset/limit` for line ranges, not entire large files");
632
- lines.push("4. Never read a large file hoping to find something — search first");
633
- }
634
-
635
- return lines.join("\n");
636
492
  }
637
493
 
638
494
  /** Resolve input as file path or literal string */
@@ -732,7 +588,7 @@ export interface BuildSystemPromptOptions {
732
588
  /** Pre-loaded skills (skips discovery if provided). */
733
589
  skills?: Skill[];
734
590
  /** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
735
- rules?: Rule[];
591
+ rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
736
592
  }
737
593
 
738
594
  /** Build the system prompt with tools, guidelines, and context */
@@ -746,7 +602,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
746
602
  cwd,
747
603
  contextFiles: providedContextFiles,
748
604
  skills: providedSkills,
749
- rules: rulebookRules,
605
+ rules,
750
606
  } = options;
751
607
  const resolvedCwd = cwd ?? process.cwd();
752
608
  const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
@@ -770,121 +626,63 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
770
626
  // Resolve context files: use provided or discover
771
627
  const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd });
772
628
 
773
- // Build tools list based on selected tools
774
- const selectedToolNames = toolNames ?? (["read", "bash", "edit", "write"] as ToolName[]);
775
- const toolsList =
776
- selectedToolNames.length > 0
777
- ? selectedToolNames.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n")
778
- : "(none)";
629
+ // Build tool descriptions array
630
+ // Priority: toolNames (explicit list) > tools (Map) > defaults
631
+ const defaultToolNames: ToolName[] = ["read", "bash", "edit", "write"];
632
+ let toolNamesArray: string[];
633
+ if (toolNames !== undefined) {
634
+ // Explicit toolNames list provided (could be empty)
635
+ toolNamesArray = toolNames;
636
+ } else if (tools !== undefined) {
637
+ // Tools map provided
638
+ toolNamesArray = Array.from(tools.keys());
639
+ } else {
640
+ // Use defaults
641
+ toolNamesArray = defaultToolNames;
642
+ }
643
+ const toolDescriptionsArray = toolNamesArray.map((name) => ({
644
+ name,
645
+ description: toolDescriptions[name as ToolName] ?? "",
646
+ }));
779
647
 
780
648
  // Resolve skills: use provided or discover
781
649
  const skills =
782
650
  providedSkills ??
783
651
  (skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).skills : []);
784
652
 
785
- if (resolvedCustomPrompt) {
786
- let prompt = systemPromptCustomization
787
- ? `${systemPromptCustomization}\n\n${resolvedCustomPrompt}`
788
- : resolvedCustomPrompt;
653
+ // Get git context
654
+ const git = loadGitContext(resolvedCwd);
789
655
 
790
- prompt = appendBlock(prompt, resolvedAppendPrompt);
791
- prompt = appendSection(prompt, "Project Context", formatProjectContext(contextFiles));
792
- prompt = appendSection(prompt, "Tools", formatToolDescriptions(tools));
793
-
794
- const gitContext = loadGitContext(resolvedCwd);
795
- prompt = appendSection(prompt, "Git Status", gitContext);
796
-
797
- if (tools?.has("read") && skills.length > 0) {
798
- prompt = appendBlock(prompt, formatSkillsForPrompt(skills));
799
- }
800
-
801
- if (rulebookRules && rulebookRules.length > 0) {
802
- prompt = appendBlock(prompt, formatRulesForPrompt(rulebookRules));
803
- }
804
-
805
- prompt = appendBlock(prompt, buildPromptFooter(dateTime, resolvedCwd), "\n");
806
-
807
- return prompt;
808
- }
809
-
810
- // Generate anti-bash rules (returns null if not applicable)
811
- const antiBashSection = generateAntiBashRules(Array.from(tools?.keys() ?? []));
812
- const environmentInfo = formatEnvironmentInfo();
813
-
814
- // Build guidelines based on which tools are actually available
815
- const guidelinesList: string[] = [];
816
-
817
- const hasBash = tools?.has("bash");
818
- const hasEdit = tools?.has("edit");
819
- const hasWrite = tools?.has("write");
656
+ // Filter skills to only include those with read tool
820
657
  const hasRead = tools?.has("read");
658
+ const filteredSkills = hasRead ? skills : [];
821
659
 
822
- // Bash without edit/write = read-only bash mode
823
- if (hasBash && !hasEdit && !hasWrite) {
824
- guidelinesList.push(
825
- "Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
826
- );
827
- }
828
-
829
- // Read before edit guideline
830
- if (hasRead && hasEdit) {
831
- guidelinesList.push("Use read to examine files before editing");
832
- }
833
-
834
- // Edit guideline
835
- if (hasEdit) {
836
- guidelinesList.push(
837
- "Use edit for precise changes (old text must match exactly, fuzzy matching handles whitespace)",
838
- );
839
- }
840
-
841
- // Write guideline
842
- if (hasWrite) {
843
- guidelinesList.push("Use write only for new files or complete rewrites");
844
- }
845
-
846
- // Output guideline (only when actually writing/executing)
847
- if (hasEdit || hasWrite) {
848
- guidelinesList.push(
849
- "When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
850
- );
851
- }
852
-
853
- // Always include these
854
- guidelinesList.push("Be concise in your responses");
855
- guidelinesList.push("Show file paths clearly when working with files");
856
-
857
- const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");
858
-
859
- // Build the prompt with anti-bash rules prominently placed
860
- const antiBashBlock = antiBashSection ? `\n${antiBashSection}\n` : "";
861
- let prompt = applyTemplate(systemPromptTemplate, {
862
- toolsList,
863
- antiBashSection: antiBashBlock,
864
- guidelines,
865
- environmentInfo,
660
+ if (resolvedCustomPrompt) {
661
+ return renderPromptTemplate(customSystemPromptTemplate, {
662
+ systemPromptCustomization: systemPromptCustomization ?? "",
663
+ customPrompt: resolvedCustomPrompt,
664
+ appendPrompt: resolvedAppendPrompt ?? "",
665
+ contextFiles,
666
+ toolDescriptions: toolDescriptionsArray,
667
+ git,
668
+ skills: filteredSkills,
669
+ rules: rules ?? [],
670
+ dateTime,
671
+ cwd: resolvedCwd,
672
+ });
673
+ }
674
+
675
+ return renderPromptTemplate(systemPromptTemplate, {
676
+ tools: toolNamesArray,
677
+ toolDescriptions: toolDescriptionsArray,
678
+ environment: getEnvironmentInfo(),
679
+ systemPromptCustomization: systemPromptCustomization ?? "",
680
+ contextFiles,
681
+ git,
682
+ skills: filteredSkills,
683
+ rules: rules ?? [],
684
+ dateTime,
685
+ cwd: resolvedCwd,
686
+ appendSystemPrompt: resolvedAppendPrompt ?? "",
866
687
  });
867
-
868
- prompt = appendBlock(prompt, resolvedAppendPrompt);
869
- prompt = appendSection(prompt, "Project Context", formatProjectContext(contextFiles));
870
-
871
- const gitContext = loadGitContext(resolvedCwd);
872
- prompt = appendSection(prompt, "Git Status", gitContext);
873
-
874
- if (hasRead && skills.length > 0) {
875
- prompt = appendBlock(prompt, formatSkillsForPrompt(skills));
876
- }
877
-
878
- if (rulebookRules && rulebookRules.length > 0) {
879
- prompt = appendBlock(prompt, formatRulesForPrompt(rulebookRules));
880
- }
881
-
882
- prompt = appendBlock(prompt, buildPromptFooter(dateTime, resolvedCwd), "\n");
883
-
884
- // Prepend SYSTEM.md customization if present
885
- if (systemPromptCustomization) {
886
- prompt = `${systemPromptCustomization}\n\n${prompt}`;
887
- }
888
-
889
- return prompt;
890
688
  }