@oh-my-pi/pi-coding-agent 12.18.3 → 12.19.2

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 (233) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +35 -27
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/hashline.ts +6 -286
  82. package/src/patch/index.ts +6 -57
  83. package/src/patch/normalize.ts +22 -65
  84. package/src/patch/shared.ts +16 -16
  85. package/src/prompts/system/custom-system-prompt.md +0 -10
  86. package/src/prompts/system/system-prompt.md +69 -89
  87. package/src/prompts/tools/async-result.md +5 -0
  88. package/src/prompts/tools/bash.md +5 -0
  89. package/src/prompts/tools/cancel-job.md +7 -0
  90. package/src/prompts/tools/hashline.md +0 -16
  91. package/src/prompts/tools/poll-jobs.md +7 -0
  92. package/src/prompts/tools/task.md +4 -0
  93. package/src/sdk.ts +70 -6
  94. package/src/session/agent-session.ts +43 -6
  95. package/src/session/agent-storage.ts +69 -278
  96. package/src/session/auth-storage.ts +14 -1430
  97. package/src/session/session-manager.ts +69 -5
  98. package/src/session/session-storage.ts +1 -5
  99. package/src/session/streaming-output.ts +637 -76
  100. package/src/slash-commands/builtin-registry.ts +8 -0
  101. package/src/ssh/connection-manager.ts +4 -12
  102. package/src/ssh/sshfs-mount.ts +3 -7
  103. package/src/ssh/utils.ts +8 -0
  104. package/src/system-prompt.ts +24 -90
  105. package/src/task/executor.ts +11 -1
  106. package/src/task/index.ts +258 -13
  107. package/src/task/parallel.ts +32 -0
  108. package/src/task/render.ts +15 -7
  109. package/src/task/types.ts +5 -0
  110. package/src/tools/ask.ts +4 -7
  111. package/src/tools/bash-interactive.ts +4 -5
  112. package/src/tools/bash.ts +125 -41
  113. package/src/tools/cancel-job.ts +93 -0
  114. package/src/tools/fetch.ts +7 -27
  115. package/src/tools/find.ts +3 -3
  116. package/src/tools/gemini-image.ts +15 -14
  117. package/src/tools/grep.ts +3 -3
  118. package/src/tools/index.ts +13 -29
  119. package/src/tools/json-tree.ts +12 -1
  120. package/src/tools/jtd-to-json-schema.ts +10 -74
  121. package/src/tools/jtd-to-typescript.ts +10 -72
  122. package/src/tools/jtd-utils.ts +102 -0
  123. package/src/tools/notebook.ts +4 -9
  124. package/src/tools/output-meta.ts +52 -26
  125. package/src/tools/path-utils.ts +13 -7
  126. package/src/tools/poll-jobs.ts +178 -0
  127. package/src/tools/python.ts +32 -35
  128. package/src/tools/read.ts +61 -82
  129. package/src/tools/render-utils.ts +8 -159
  130. package/src/tools/ssh.ts +7 -20
  131. package/src/tools/submit-result.ts +1 -1
  132. package/src/tools/tool-errors.ts +0 -30
  133. package/src/tools/tool-result.ts +1 -2
  134. package/src/tools/write.ts +8 -10
  135. package/src/tui/code-cell.ts +8 -3
  136. package/src/tui/status-line.ts +4 -4
  137. package/src/tui/types.ts +0 -1
  138. package/src/tui/utils.ts +1 -14
  139. package/src/utils/command-args.ts +76 -0
  140. package/src/utils/file-mentions.ts +15 -19
  141. package/src/utils/frontmatter.ts +5 -10
  142. package/src/utils/shell-snapshot.ts +0 -11
  143. package/src/utils/title-generator.ts +0 -12
  144. package/src/web/scrapers/artifacthub.ts +7 -16
  145. package/src/web/scrapers/arxiv.ts +3 -8
  146. package/src/web/scrapers/aur.ts +8 -22
  147. package/src/web/scrapers/biorxiv.ts +5 -14
  148. package/src/web/scrapers/bluesky.ts +13 -36
  149. package/src/web/scrapers/brew.ts +5 -10
  150. package/src/web/scrapers/cheatsh.ts +2 -12
  151. package/src/web/scrapers/chocolatey.ts +63 -26
  152. package/src/web/scrapers/choosealicense.ts +3 -18
  153. package/src/web/scrapers/cisa-kev.ts +4 -18
  154. package/src/web/scrapers/clojars.ts +6 -33
  155. package/src/web/scrapers/coingecko.ts +25 -33
  156. package/src/web/scrapers/crates-io.ts +7 -26
  157. package/src/web/scrapers/crossref.ts +4 -18
  158. package/src/web/scrapers/devto.ts +11 -41
  159. package/src/web/scrapers/discogs.ts +7 -10
  160. package/src/web/scrapers/discourse.ts +6 -31
  161. package/src/web/scrapers/dockerhub.ts +12 -35
  162. package/src/web/scrapers/fdroid.ts +8 -33
  163. package/src/web/scrapers/firefox-addons.ts +10 -34
  164. package/src/web/scrapers/flathub.ts +7 -24
  165. package/src/web/scrapers/github-gist.ts +2 -12
  166. package/src/web/scrapers/github.ts +9 -47
  167. package/src/web/scrapers/gitlab.ts +130 -185
  168. package/src/web/scrapers/go-pkg.ts +12 -22
  169. package/src/web/scrapers/hackage.ts +88 -43
  170. package/src/web/scrapers/hackernews.ts +25 -45
  171. package/src/web/scrapers/hex.ts +19 -36
  172. package/src/web/scrapers/huggingface.ts +26 -91
  173. package/src/web/scrapers/iacr.ts +3 -8
  174. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  175. package/src/web/scrapers/lemmy.ts +5 -23
  176. package/src/web/scrapers/lobsters.ts +16 -28
  177. package/src/web/scrapers/mastodon.ts +24 -43
  178. package/src/web/scrapers/maven.ts +6 -21
  179. package/src/web/scrapers/mdn.ts +7 -11
  180. package/src/web/scrapers/metacpan.ts +9 -41
  181. package/src/web/scrapers/musicbrainz.ts +4 -28
  182. package/src/web/scrapers/npm.ts +8 -25
  183. package/src/web/scrapers/nuget.ts +14 -37
  184. package/src/web/scrapers/nvd.ts +6 -28
  185. package/src/web/scrapers/ollama.ts +7 -34
  186. package/src/web/scrapers/open-vsx.ts +5 -19
  187. package/src/web/scrapers/opencorporates.ts +30 -14
  188. package/src/web/scrapers/openlibrary.ts +49 -33
  189. package/src/web/scrapers/orcid.ts +4 -18
  190. package/src/web/scrapers/osv.ts +7 -24
  191. package/src/web/scrapers/packagist.ts +9 -24
  192. package/src/web/scrapers/pub-dev.ts +7 -50
  193. package/src/web/scrapers/pubmed.ts +54 -21
  194. package/src/web/scrapers/pypi.ts +8 -26
  195. package/src/web/scrapers/rawg.ts +11 -19
  196. package/src/web/scrapers/readthedocs.ts +4 -9
  197. package/src/web/scrapers/reddit.ts +5 -15
  198. package/src/web/scrapers/repology.ts +8 -20
  199. package/src/web/scrapers/rfc.ts +5 -14
  200. package/src/web/scrapers/rubygems.ts +6 -21
  201. package/src/web/scrapers/searchcode.ts +8 -36
  202. package/src/web/scrapers/sec-edgar.ts +4 -18
  203. package/src/web/scrapers/semantic-scholar.ts +15 -35
  204. package/src/web/scrapers/snapcraft.ts +5 -19
  205. package/src/web/scrapers/sourcegraph.ts +5 -43
  206. package/src/web/scrapers/spdx.ts +4 -18
  207. package/src/web/scrapers/spotify.ts +4 -23
  208. package/src/web/scrapers/stackoverflow.ts +8 -13
  209. package/src/web/scrapers/terraform.ts +9 -37
  210. package/src/web/scrapers/tldr.ts +3 -7
  211. package/src/web/scrapers/twitter.ts +3 -7
  212. package/src/web/scrapers/types.ts +105 -27
  213. package/src/web/scrapers/utils.ts +97 -103
  214. package/src/web/scrapers/vimeo.ts +7 -27
  215. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  216. package/src/web/scrapers/w3c.ts +6 -14
  217. package/src/web/scrapers/wikidata.ts +5 -19
  218. package/src/web/scrapers/wikipedia.ts +2 -12
  219. package/src/web/scrapers/youtube.ts +5 -34
  220. package/src/web/search/index.ts +0 -9
  221. package/src/web/search/providers/anthropic.ts +3 -2
  222. package/src/web/search/providers/brave.ts +3 -18
  223. package/src/web/search/providers/exa.ts +1 -12
  224. package/src/web/search/providers/kimi.ts +5 -44
  225. package/src/web/search/providers/perplexity.ts +1 -12
  226. package/src/web/search/providers/synthetic.ts +3 -26
  227. package/src/web/search/providers/utils.ts +36 -0
  228. package/src/web/search/providers/zai.ts +9 -50
  229. package/src/web/search/types.ts +0 -28
  230. package/src/web/search/utils.ts +17 -0
  231. package/src/tools/output-utils.ts +0 -63
  232. package/src/tools/truncate.ts +0 -385
  233. package/src/web/search/auth.ts +0 -178
@@ -115,7 +115,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
115
115
  clearThinkingLine();
116
116
  const assistantMessage = event.message as { stopReason?: string; errorMessage?: string };
117
117
  if (assistantMessage.stopReason === "error" && assistantMessage.errorMessage) {
118
- writeStdout(`● Error: ${assistantMessage.errorMessage}`);
118
+ process.stdout.write(`● Error: ${assistantMessage.errorMessage}\n`);
119
119
  }
120
120
  const messageText = extractMessageText(event.message?.content ?? []);
121
121
  if (messageText) {
@@ -130,10 +130,10 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
130
130
  clearThinkingLine();
131
131
  const toolLabel = formatToolLabel(stored.name);
132
132
  const symbol = event.isError ? "" : "";
133
- writeStdout(`${symbol} ${toolLabel}`);
133
+ process.stdout.write(`${symbol} ${toolLabel}\n`);
134
134
  const argsLines = formatToolArgs(stored.args);
135
135
  if (argsLines.length > 0) {
136
- writeStdout(formatToolArgsBlock(argsLines));
136
+ process.stdout.write(`${formatToolArgsBlock(argsLines)}\n`);
137
137
  }
138
138
  break;
139
139
  }
@@ -141,7 +141,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
141
141
  if (isThinking) {
142
142
  isThinking = false;
143
143
  }
144
- writeStdout(`● agent finished (${messageCount} messages, ${toolCalls} tools)`);
144
+ process.stdout.write(`● agent finished (${messageCount} messages, ${toolCalls} tools)\n`);
145
145
  break;
146
146
  default:
147
147
  break;
@@ -172,10 +172,6 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
172
172
  }
173
173
  }
174
174
 
175
- function writeStdout(message: string): void {
176
- process.stdout.write(`${message}\n`);
177
- }
178
-
179
175
  function extractMessagePreview(content: Array<{ type: string; text?: string }>): string | null {
180
176
  const textBlocks = content
181
177
  .filter(block => block.type === "text" && typeof block.text === "string")
@@ -204,7 +200,7 @@ function writeAssistantMessage(message: string): void {
204
200
  }
205
201
  for (const [index, line] of lines.entries()) {
206
202
  const prefix = index === firstContentIndex ? "● " : " ";
207
- writeStdout(`${prefix}${line}`.trimEnd());
203
+ process.stdout.write(`${`${prefix}${line}`.trimEnd()}\n`);
208
204
  }
209
205
  }
210
206
 
@@ -249,6 +245,7 @@ function formatToolArgs(args?: Record<string, unknown>): string[] {
249
245
  }
250
246
  };
251
247
  for (const [key, value] of Object.entries(args)) {
248
+ if (key === "agent__intent") continue;
252
249
  visit(value, key);
253
250
  }
254
251
  return lines;
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { createInterface } from "node:readline/promises";
3
- import { $env } from "@oh-my-pi/pi-utils";
3
+ import { $env, isEnoent } from "@oh-my-pi/pi-utils";
4
4
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
5
5
  import { applyChangelogProposals } from "../../commit/changelog";
6
6
  import { detectChangelogBoundaries } from "../../commit/changelog/detect";
@@ -31,13 +31,13 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
31
31
  const git = new ControlledGit(cwd);
32
32
  const [settings, authStorage] = await Promise.all([Settings.init({ cwd }), discoverAuthStorage()]);
33
33
 
34
- writeStdout("● Resolving model...");
34
+ process.stdout.write("● Resolving model...\n");
35
35
  const modelRegistry = new ModelRegistry(authStorage);
36
36
  await modelRegistry.refresh();
37
37
  const stagedFilesPromise = (async () => {
38
38
  let stagedFiles = await git.getStagedFiles();
39
39
  if (stagedFiles.length === 0) {
40
- writeStdout("No staged changes detected, staging all changes...");
40
+ process.stdout.write("No staged changes detected, staging all changes...\n");
41
41
  await git.stageAll();
42
42
  stagedFiles = await git.getStagedFiles();
43
43
  }
@@ -47,17 +47,17 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
47
47
  const primaryModelPromise = resolvePrimaryModel(args.model, settings, modelRegistry);
48
48
  const [primaryModelResult, stagedFiles] = await Promise.all([primaryModelPromise, stagedFilesPromise]);
49
49
  const { model: primaryModel, apiKey: primaryApiKey } = primaryModelResult;
50
- writeStdout(` └─ ${primaryModel.name}`);
50
+ process.stdout.write(` └─ ${primaryModel.name}\n`);
51
51
 
52
52
  const { model: agentModel } = await resolveSmolModel(settings, modelRegistry, primaryModel, primaryApiKey);
53
53
 
54
54
  if (stagedFiles.length === 0) {
55
- writeStderr("No changes to commit.");
55
+ process.stderr.write("No changes to commit.\n");
56
56
  return;
57
57
  }
58
58
 
59
59
  if (!args.noChangelog) {
60
- writeStdout("● Detecting changelog targets...");
60
+ process.stdout.write("● Detecting changelog targets...\n");
61
61
  }
62
62
  const [changelogBoundaries, contextFiles, numstat, diff] = await Promise.all([
63
63
  args.noChangelog ? [] : detectChangelogBoundaries(cwd, stagedFiles),
@@ -69,25 +69,25 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
69
69
  if (!args.noChangelog) {
70
70
  if (changelogTargets.length > 0) {
71
71
  for (const path of changelogTargets) {
72
- writeStdout(` └─ ${path}`);
72
+ process.stdout.write(` └─ ${path}\n`);
73
73
  }
74
74
  } else {
75
- writeStdout(" └─ (none found)");
75
+ process.stdout.write(" └─ (none found)\n");
76
76
  }
77
77
  }
78
78
 
79
- writeStdout("● Discovering context files...");
79
+ process.stdout.write("● Discovering context files...\n");
80
80
  const agentsMdFiles = contextFiles.filter(file => file.path.endsWith("AGENTS.md"));
81
81
  if (agentsMdFiles.length > 0) {
82
82
  for (const file of agentsMdFiles) {
83
- writeStdout(` └─ ${file.path}`);
83
+ process.stdout.write(` └─ ${file.path}\n`);
84
84
  }
85
85
  } else {
86
- writeStdout(" └─ (none found)");
86
+ process.stdout.write(" └─ (none found)\n");
87
87
  }
88
88
  const forceFallback = $env.PI_COMMIT_TEST_FALLBACK?.toLowerCase() === "true";
89
89
  if (forceFallback) {
90
- writeStdout("● Forcing fallback commit generation...");
90
+ process.stdout.write("● Forcing fallback commit generation...\n");
91
91
  const fallbackProposal = generateFallbackProposal(numstat);
92
92
  await runSingleCommit(fallbackProposal, { git, dryRun: args.dryRun, push: args.push });
93
93
  return;
@@ -95,7 +95,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
95
95
 
96
96
  const trivialChange = detectTrivialChange(diff);
97
97
  if (trivialChange) {
98
- writeStdout(`● Detected trivial change: ${trivialChange.summary}`);
98
+ process.stdout.write(`● Detected trivial change: ${trivialChange.summary}\n`);
99
99
  const trivialProposal: CommitProposal = {
100
100
  analysis: {
101
101
  type: trivialChange.type,
@@ -118,7 +118,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
118
118
  }
119
119
  }
120
120
 
121
- writeStdout("● Starting commit agent...");
121
+ process.stdout.write("● Starting commit agent...\n");
122
122
  let commitState: CommitAgentState;
123
123
  let usedFallback = false;
124
124
 
@@ -139,18 +139,18 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
139
139
  });
140
140
  } catch (error) {
141
141
  const errorMessage = error instanceof Error ? error.message : String(error);
142
- writeStderr(`Agent error: ${errorMessage}`);
142
+ process.stderr.write(`Agent error: ${errorMessage}\n`);
143
143
  if (error instanceof Error && error.stack && $env.DEBUG) {
144
- writeStderr(error.stack);
144
+ process.stderr.write(`${error.stack}\n`);
145
145
  }
146
- writeStdout("● Using fallback commit generation...");
146
+ process.stdout.write("● Using fallback commit generation...\n");
147
147
  commitState = { proposal: generateFallbackProposal(numstat) };
148
148
  usedFallback = true;
149
149
  }
150
150
 
151
151
  if (!usedFallback && !commitState.proposal && !commitState.splitProposal) {
152
152
  if ($env.PI_COMMIT_NO_FALLBACK?.toLowerCase() !== "true") {
153
- writeStdout("● Agent did not provide proposal, using fallback...");
153
+ process.stdout.write("● Agent did not provide proposal, using fallback...\n");
154
154
  commitState.proposal = generateFallbackProposal(numstat);
155
155
  usedFallback = true;
156
156
  }
@@ -159,26 +159,26 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
159
159
  let updatedChangelogFiles: string[] = [];
160
160
  if (!args.noChangelog && changelogTargets.length > 0 && !usedFallback) {
161
161
  if (!commitState.changelogProposal) {
162
- writeStderr("Commit agent did not provide changelog entries.");
162
+ process.stderr.write("Commit agent did not provide changelog entries.\n");
163
163
  return;
164
164
  }
165
- writeStdout("● Applying changelog entries...");
165
+ process.stdout.write("● Applying changelog entries...\n");
166
166
  const updated = await applyChangelogProposals({
167
167
  git,
168
168
  cwd,
169
169
  proposals: commitState.changelogProposal.entries,
170
170
  dryRun: args.dryRun,
171
171
  onProgress: message => {
172
- writeStdout(` ├─ ${message}`);
172
+ process.stdout.write(` ├─ ${message}\n`);
173
173
  },
174
174
  });
175
175
  updatedChangelogFiles = updated.map(filePath => path.relative(cwd, filePath));
176
176
  if (updated.length > 0) {
177
177
  for (const filePath of updated) {
178
- writeStdout(` └─ ${filePath}`);
178
+ process.stdout.write(` └─ ${filePath}\n`);
179
179
  }
180
180
  } else {
181
- writeStdout(" └─ (no changes)");
181
+ process.stdout.write(" └─ (no changes)\n");
182
182
  }
183
183
  }
184
184
 
@@ -197,24 +197,24 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
197
197
  return;
198
198
  }
199
199
 
200
- writeStderr("Commit agent did not provide a proposal.");
200
+ process.stderr.write("Commit agent did not provide a proposal.\n");
201
201
  }
202
202
 
203
203
  async function runSingleCommit(proposal: CommitProposal, ctx: CommitExecutionContext): Promise<void> {
204
204
  if (proposal.warnings.length > 0) {
205
- writeStdout(formatWarnings(proposal.warnings));
205
+ process.stdout.write(formatWarnings(proposal.warnings));
206
206
  }
207
207
  const commitMessage = formatCommitMessage(proposal.analysis, proposal.summary);
208
208
  if (ctx.dryRun) {
209
- writeStdout("\nGenerated commit message:\n");
210
- writeStdout(commitMessage);
209
+ process.stdout.write("\nGenerated commit message:\n");
210
+ process.stdout.write(`${commitMessage}\n`);
211
211
  return;
212
212
  }
213
213
  await ctx.git.commit(commitMessage);
214
- writeStdout("Commit created.");
214
+ process.stdout.write("Commit created.\n");
215
215
  if (ctx.push) {
216
216
  await ctx.git.push();
217
- writeStdout("Pushed to remote.");
217
+ process.stdout.write("Pushed to remote.\n");
218
218
  }
219
219
  }
220
220
 
@@ -223,7 +223,7 @@ async function runSplitCommit(
223
223
  ctx: CommitExecutionContext & { additionalFiles?: string[] },
224
224
  ): Promise<void> {
225
225
  if (plan.warnings.length > 0) {
226
- writeStdout(formatWarnings(plan.warnings));
226
+ process.stdout.write(formatWarnings(plan.warnings));
227
227
  }
228
228
  if (ctx.additionalFiles && ctx.additionalFiles.length > 0) {
229
229
  appendFilesToLastCommit(plan, ctx.additionalFiles);
@@ -232,12 +232,12 @@ async function runSplitCommit(
232
232
  const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
233
233
  const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file));
234
234
  if (missingFiles.length > 0) {
235
- writeStderr(`Split commit plan missing staged files: ${missingFiles.join(", ")}`);
235
+ process.stderr.write(`Split commit plan missing staged files: ${missingFiles.join(", ")}\n`);
236
236
  return;
237
237
  }
238
238
 
239
239
  if (ctx.dryRun) {
240
- writeStdout("\nSplit commit plan (dry run):\n");
240
+ process.stdout.write("\nSplit commit plan (dry run):\n");
241
241
  for (const [index, commit] of plan.commits.entries()) {
242
242
  const analysis: ConventionalAnalysis = {
243
243
  type: commit.type,
@@ -246,17 +246,17 @@ async function runSplitCommit(
246
246
  issueRefs: commit.issueRefs,
247
247
  };
248
248
  const message = formatCommitMessage(analysis, commit.summary);
249
- writeStdout(`Commit ${index + 1}:\n${message}\n`);
249
+ process.stdout.write(`Commit ${index + 1}:\n${message}\n`);
250
250
  const changeSummary = commit.changes
251
251
  .map(change => formatFileChangeSummary(change.path, change.hunks))
252
252
  .join(", ");
253
- writeStdout(`Changes: ${changeSummary}\n`);
253
+ process.stdout.write(`Changes: ${changeSummary}\n`);
254
254
  }
255
255
  return;
256
256
  }
257
257
 
258
258
  if (!(await confirmSplitCommitPlan(plan))) {
259
- writeStdout("Split commit aborted by user.");
259
+ process.stdout.write("Split commit aborted by user.\n");
260
260
  return;
261
261
  }
262
262
 
@@ -279,10 +279,10 @@ async function runSplitCommit(
279
279
  await ctx.git.commit(message);
280
280
  await ctx.git.resetStaging();
281
281
  }
282
- writeStdout("Split commits created.");
282
+ process.stdout.write("Split commits created.\n");
283
283
  if (ctx.push) {
284
284
  await ctx.git.push();
285
- writeStdout("Pushed to remote.");
285
+ process.stdout.write("Pushed to remote.\n");
286
286
  }
287
287
  }
288
288
 
@@ -312,15 +312,7 @@ async function confirmSplitCommitPlan(plan: SplitCommitPlan): Promise<boolean> {
312
312
  }
313
313
 
314
314
  function formatWarnings(warnings: string[]): string {
315
- return `Warnings:\n${warnings.map(warning => `- ${warning}`).join("\n")}`;
316
- }
317
-
318
- function writeStdout(message: string): void {
319
- process.stdout.write(`${message}\n`);
320
- }
321
-
322
- function writeStderr(message: string): void {
323
- process.stderr.write(`${message}\n`);
315
+ return `Warnings:\n${warnings.map(warning => `- ${warning}`).join("\n")}\n`;
324
316
  }
325
317
 
326
318
  function formatFileChangeSummary(path: string, hunks: HunkSelector): string {
@@ -336,11 +328,13 @@ function formatFileChangeSummary(path: string, hunks: HunkSelector): string {
336
328
  async function loadExistingChangelogEntries(paths: string[]): Promise<ExistingChangelogEntries[]> {
337
329
  const entries = await Promise.all(
338
330
  paths.map(async path => {
339
- const file = Bun.file(path);
340
- if (!(await file.exists())) {
341
- return null;
331
+ let content: string;
332
+ try {
333
+ content = await Bun.file(path).text();
334
+ } catch (err) {
335
+ if (isEnoent(err)) return null;
336
+ throw err;
342
337
  }
343
- const content = await file.text();
344
338
  try {
345
339
  const unreleased = parseUnreleasedSection(content);
346
340
  const sections = Object.entries(unreleased.entries)
@@ -16,15 +16,6 @@ export interface CommitProposal {
16
16
  warnings: string[];
17
17
  }
18
18
 
19
- export interface FileObservation {
20
- file: string;
21
- summary: string;
22
- highlights: string[];
23
- risks: string[];
24
- additions: number;
25
- deletions: number;
26
- }
27
-
28
19
  export type HunkSelector =
29
20
  | { type: "all" }
30
21
  | { type: "indices"; indices: number[] }
@@ -12,36 +12,7 @@ import { validateAnalysis } from "../../../commit/analysis/validation";
12
12
  import type { ControlledGit } from "../../../commit/git";
13
13
  import type { CommitType, ConventionalAnalysis, ConventionalDetail } from "../../../commit/types";
14
14
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
15
-
16
- const commitTypeSchema = Type.Union([
17
- Type.Literal("feat"),
18
- Type.Literal("fix"),
19
- Type.Literal("refactor"),
20
- Type.Literal("perf"),
21
- Type.Literal("docs"),
22
- Type.Literal("test"),
23
- Type.Literal("build"),
24
- Type.Literal("ci"),
25
- Type.Literal("chore"),
26
- Type.Literal("style"),
27
- Type.Literal("revert"),
28
- ]);
29
-
30
- const detailSchema = Type.Object({
31
- text: Type.String(),
32
- changelog_category: Type.Optional(
33
- Type.Union([
34
- Type.Literal("Added"),
35
- Type.Literal("Changed"),
36
- Type.Literal("Fixed"),
37
- Type.Literal("Deprecated"),
38
- Type.Literal("Removed"),
39
- Type.Literal("Security"),
40
- Type.Literal("Breaking Changes"),
41
- ]),
42
- ),
43
- user_visible: Type.Optional(Type.Boolean()),
44
- });
15
+ import { commitTypeSchema, detailSchema } from "./schemas.js";
45
16
 
46
17
  const proposeCommitSchema = Type.Object({
47
18
  type: commitTypeSchema,
@@ -0,0 +1,31 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const commitTypeSchema = Type.Union([
4
+ Type.Literal("feat"),
5
+ Type.Literal("fix"),
6
+ Type.Literal("refactor"),
7
+ Type.Literal("perf"),
8
+ Type.Literal("docs"),
9
+ Type.Literal("test"),
10
+ Type.Literal("build"),
11
+ Type.Literal("ci"),
12
+ Type.Literal("chore"),
13
+ Type.Literal("style"),
14
+ Type.Literal("revert"),
15
+ ]);
16
+
17
+ export const detailSchema = Type.Object({
18
+ text: Type.String(),
19
+ changelog_category: Type.Optional(
20
+ Type.Union([
21
+ Type.Literal("Added"),
22
+ Type.Literal("Changed"),
23
+ Type.Literal("Fixed"),
24
+ Type.Literal("Deprecated"),
25
+ Type.Literal("Removed"),
26
+ Type.Literal("Security"),
27
+ Type.Literal("Breaking Changes"),
28
+ ]),
29
+ ),
30
+ user_visible: Type.Optional(Type.Boolean()),
31
+ });
@@ -13,36 +13,7 @@ import { validateScope } from "../../../commit/analysis/validation";
13
13
  import type { ControlledGit } from "../../../commit/git";
14
14
  import type { ConventionalDetail } from "../../../commit/types";
15
15
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
16
-
17
- const commitTypeSchema = Type.Union([
18
- Type.Literal("feat"),
19
- Type.Literal("fix"),
20
- Type.Literal("refactor"),
21
- Type.Literal("perf"),
22
- Type.Literal("docs"),
23
- Type.Literal("test"),
24
- Type.Literal("build"),
25
- Type.Literal("ci"),
26
- Type.Literal("chore"),
27
- Type.Literal("style"),
28
- Type.Literal("revert"),
29
- ]);
30
-
31
- const detailSchema = Type.Object({
32
- text: Type.String(),
33
- changelog_category: Type.Optional(
34
- Type.Union([
35
- Type.Literal("Added"),
36
- Type.Literal("Changed"),
37
- Type.Literal("Fixed"),
38
- Type.Literal("Deprecated"),
39
- Type.Literal("Removed"),
40
- Type.Literal("Security"),
41
- Type.Literal("Breaking Changes"),
42
- ]),
43
- ),
44
- user_visible: Type.Optional(Type.Boolean()),
45
- });
16
+ import { commitTypeSchema, detailSchema } from "./schemas.js";
46
17
 
47
18
  const hunkSelectorSchema = Type.Union([
48
19
  Type.Object({ type: Type.Literal("all") }),
@@ -1,6 +1,7 @@
1
1
  import { stripTypePrefix } from "../../commit/analysis/summary";
2
2
  import { validateSummary } from "../../commit/analysis/validation";
3
3
  import type { CommitType, ConventionalDetail } from "../../commit/types";
4
+ import { normalizeUnicode } from "../../patch/normalize";
4
5
 
5
6
  export const SUMMARY_MAX_CHARS = 72;
6
7
  export const MAX_DETAIL_ITEMS = 6;
@@ -61,29 +62,11 @@ const pastTenseVerbs = new Set([
61
62
  ]);
62
63
  const pastTenseEdExceptions = new Set(["hundred", "red", "bed"]);
63
64
 
64
- const unicodeReplacements: Array<[RegExp, string]> = [
65
- [/[\u2018\u2019]/g, "'"],
66
- [/[\u201C\u201D]/g, '"'],
67
- [/[\u2013\u2014\u2212]/g, "-"],
68
- [/\u2260/g, "!="],
69
- [/\u00BD/g, "1/2"],
70
- [/\u03BB/g, "lambda"],
71
- [/[\u200B-\u200D\uFEFF]/g, ""],
72
- ];
73
-
74
65
  export function normalizeSummary(summary: string, type: CommitType, scope: string | null): string {
75
66
  const stripped = stripTypePrefix(summary, type, scope);
76
67
  return normalizeUnicode(stripped).replace(/\s+/g, " ").trim();
77
68
  }
78
69
 
79
- export function normalizeUnicode(text: string): string {
80
- let result = text;
81
- for (const [pattern, replacement] of unicodeReplacements) {
82
- result = result.replace(pattern, replacement);
83
- }
84
- return result.normalize("NFC");
85
- }
86
-
87
70
  export function validateSummaryRules(summary: string): { errors: string[]; warnings: string[] } {
88
71
  const errors: string[] = [];
89
72
  const warnings: string[] = [];
@@ -1,10 +1,11 @@
1
- import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
1
+ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
5
5
  import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
6
- import type { ChangelogCategory, ConventionalAnalysis, ConventionalDetail } from "../../commit/types";
6
+ import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types";
7
7
  import { renderPromptTemplate } from "../../config/prompt-templates";
8
+ import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
8
9
 
9
10
  const ConventionalAnalysisTool = {
10
11
  name: "create_conventional_analysis",
@@ -115,51 +116,3 @@ function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnaly
115
116
  };
116
117
  return normalizeAnalysis(parsed);
117
118
  }
118
-
119
- function normalizeAnalysis(parsed: {
120
- type: ConventionalAnalysis["type"];
121
- scope: string | null;
122
- details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
123
- issue_refs: string[];
124
- }): ConventionalAnalysis {
125
- const details: ConventionalDetail[] = parsed.details.map(detail => ({
126
- text: detail.text.trim(),
127
- changelogCategory: detail.user_visible ? detail.changelog_category : undefined,
128
- userVisible: detail.user_visible ?? false,
129
- }));
130
- return {
131
- type: parsed.type,
132
- scope: parsed.scope?.trim() || null,
133
- details,
134
- issueRefs: parsed.issue_refs ?? [],
135
- };
136
- }
137
-
138
- function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
139
- for (const content of message.content) {
140
- if (content.type === "toolCall" && content.name === name) {
141
- return content;
142
- }
143
- }
144
- return undefined;
145
- }
146
-
147
- function extractTextContent(message: AssistantMessage): string {
148
- return message.content
149
- .filter(content => content.type === "text")
150
- .map(content => content.text)
151
- .join("")
152
- .trim();
153
- }
154
-
155
- function parseJsonPayload(text: string): unknown {
156
- const trimmed = text.trim();
157
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
158
- return JSON.parse(trimmed) as unknown;
159
- }
160
- const match = trimmed.match(/\{[\s\S]*\}/);
161
- if (!match) {
162
- throw new Error("No JSON payload found in analysis response");
163
- }
164
- return JSON.parse(match[0]) as unknown;
165
- }
@@ -1,10 +1,11 @@
1
- import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
1
+ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" };
5
5
  import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" };
6
6
  import type { CommitSummary } from "../../commit/types";
7
7
  import { renderPromptTemplate } from "../../config/prompt-templates";
8
+ import { extractTextContent, extractToolCall } from "../utils";
8
9
 
9
10
  const SummaryTool = {
10
11
  name: "create_commit_summary",
@@ -85,18 +86,6 @@ function parseSummaryFromResponse(message: AssistantMessage, commitType: string,
85
86
  return { summary: stripTypePrefix(text, commitType, scope) };
86
87
  }
87
88
 
88
- function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
89
- return message.content.find(content => content.type === "toolCall" && content.name === name) as ToolCall | undefined;
90
- }
91
-
92
- function extractTextContent(message: AssistantMessage): string {
93
- return message.content
94
- .filter(content => content.type === "text")
95
- .map(content => content.text)
96
- .join("")
97
- .trim();
98
- }
99
-
100
89
  export function stripTypePrefix(summary: string, commitType: string, scope: string | null): string {
101
90
  const trimmed = summary.trim();
102
91
  const scopePart = scope ? `(${scope})` : "";
@@ -26,8 +26,11 @@ async function findNearestChangelog(cwd: string, filePath: string): Promise<stri
26
26
  const root = path.resolve(cwd);
27
27
  while (true) {
28
28
  const candidate = path.resolve(current, CHANGELOG_NAME);
29
- if (fs.existsSync(candidate)) {
29
+ try {
30
+ await fs.promises.access(candidate);
30
31
  return candidate;
32
+ } catch {
33
+ // not found, continue traversal
31
34
  }
32
35
  if (current === root) return null;
33
36
  const parent = path.dirname(current);
@@ -1,10 +1,11 @@
1
- import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
1
+ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" };
5
5
  import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" };
6
6
  import type { ChangelogGenerationResult } from "../../commit/types";
7
7
  import { renderPromptTemplate } from "../../config/prompt-templates";
8
+ import { extractTextContent, extractToolCall, parseJsonPayload } from "../utils";
8
9
 
9
10
  const ChangelogTool = {
10
11
  name: "create_changelog_entries",
@@ -66,30 +67,6 @@ function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationR
66
67
  return { entries: parsed.entries ?? {} };
67
68
  }
68
69
 
69
- function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
70
- return message.content.find(content => content.type === "toolCall" && content.name === name) as ToolCall | undefined;
71
- }
72
-
73
- function extractTextContent(message: AssistantMessage): string {
74
- return message.content
75
- .filter(content => content.type === "text")
76
- .map(content => content.text)
77
- .join("")
78
- .trim();
79
- }
80
-
81
- function parseJsonPayload(text: string): unknown {
82
- const trimmed = text.trim();
83
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
84
- return JSON.parse(trimmed) as unknown;
85
- }
86
- const match = trimmed.match(/\{[\s\S]*\}/);
87
- if (!match) {
88
- throw new Error("No JSON payload found in changelog response");
89
- }
90
- return JSON.parse(match[0]) as unknown;
91
- }
92
-
93
70
  function dedupeEntries(entries: Record<string, string[]>): Record<string, string[]> {
94
71
  const result: Record<string, string[]> = {};
95
72
  for (const [category, values] of Object.entries(entries)) {
@@ -1,4 +1,3 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import type { Api, Model } from "@oh-my-pi/pi-ai";
4
3
  import { logger } from "@oh-my-pi/pi-utils";
@@ -109,7 +108,7 @@ export async function applyChangelogProposals({
109
108
  )
110
109
  continue;
111
110
  onProgress?.(`Applying entries for ${proposal.path}…`);
112
- const exists = fs.existsSync(proposal.path);
111
+ const exists = await Bun.file(proposal.path).exists();
113
112
  if (!exists) {
114
113
  logger.warn("commit changelog path missing", { path: proposal.path });
115
114
  continue;