@oh-my-pi/pi-coding-agent 16.1.22 → 16.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 (276) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/dist/cli.js +4387 -4164
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/cli/gc-cli.d.ts +58 -0
  11. package/dist/types/collab/display-name.d.ts +3 -0
  12. package/dist/types/collab/host.d.ts +0 -2
  13. package/dist/types/commands/gc.d.ts +37 -0
  14. package/dist/types/commands/worktree.d.ts +0 -3
  15. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  16. package/dist/types/config/model-discovery.d.ts +6 -1
  17. package/dist/types/config/model-registry.d.ts +6 -2
  18. package/dist/types/config/model-resolver.d.ts +12 -0
  19. package/dist/types/config/models-config-schema.d.ts +29 -2
  20. package/dist/types/config/models-config.d.ts +22 -1
  21. package/dist/types/config/settings-schema.d.ts +145 -21
  22. package/dist/types/config/settings.d.ts +13 -0
  23. package/dist/types/dap/config.d.ts +1 -1
  24. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  25. package/dist/types/edit/renderer.d.ts +4 -0
  26. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  27. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  28. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  29. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  30. package/dist/types/internal-urls/index.d.ts +1 -0
  31. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  32. package/dist/types/internal-urls/router.d.ts +1 -1
  33. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  34. package/dist/types/internal-urls/types.d.ts +19 -2
  35. package/dist/types/irc/bus.d.ts +6 -0
  36. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  37. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  38. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  39. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  40. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  41. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  42. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  43. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  44. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  45. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  46. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  49. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  51. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  52. package/dist/types/modes/interactive-mode.d.ts +10 -1
  53. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  54. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  55. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  56. package/dist/types/modes/theme/theme.d.ts +2 -1
  57. package/dist/types/modes/types.d.ts +9 -1
  58. package/dist/types/sdk.d.ts +2 -2
  59. package/dist/types/session/agent-session.d.ts +2 -1
  60. package/dist/types/session/session-listing.d.ts +10 -1
  61. package/dist/types/session/session-manager.d.ts +13 -0
  62. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  63. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  64. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  65. package/dist/types/ssh/connection-manager.d.ts +2 -0
  66. package/dist/types/ssh/file-transfer.d.ts +79 -0
  67. package/dist/types/ssh/utils.d.ts +6 -0
  68. package/dist/types/system-prompt.d.ts +5 -0
  69. package/dist/types/task/executor.d.ts +16 -0
  70. package/dist/types/tiny/text.d.ts +1 -1
  71. package/dist/types/tools/builtin-names.d.ts +5 -1
  72. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  73. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  74. package/dist/types/tools/index.d.ts +3 -3
  75. package/dist/types/tools/path-utils.d.ts +29 -0
  76. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  77. package/dist/types/tools/read.d.ts +2 -2
  78. package/dist/types/tools/render-utils.d.ts +8 -0
  79. package/dist/types/tools/renderers.d.ts +11 -0
  80. package/dist/types/tools/ssh.d.ts +2 -0
  81. package/dist/types/tools/todo.d.ts +0 -16
  82. package/dist/types/tools/write.d.ts +2 -2
  83. package/dist/types/utils/active-repo-context.d.ts +8 -0
  84. package/dist/types/utils/image-resize.d.ts +1 -0
  85. package/dist/types/utils/markit-cache.d.ts +23 -0
  86. package/dist/types/utils/markit.d.ts +5 -1
  87. package/dist/types/utils/prompt-path.d.ts +1 -0
  88. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  89. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  90. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  91. package/dist/types/web/search/providers/xai.d.ts +13 -0
  92. package/dist/types/web/search/types.d.ts +18 -2
  93. package/package.json +30 -15
  94. package/scripts/bench-guard.ts +1 -1
  95. package/scripts/build-binary.ts +9 -9
  96. package/scripts/bundle-dist.ts +2 -2
  97. package/src/advisor/__tests__/advisor.test.ts +40 -4
  98. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  99. package/src/advisor/advise-tool.ts +1 -1
  100. package/src/advisor/emission-guard.ts +172 -0
  101. package/src/advisor/index.ts +1 -0
  102. package/src/advisor/runtime.ts +11 -0
  103. package/src/advisor/watchdog.ts +12 -1
  104. package/src/cli/args.ts +5 -2
  105. package/src/cli/auth-broker-cli.ts +17 -0
  106. package/src/cli/config-cli.ts +4 -0
  107. package/src/cli/flag-tables.ts +7 -4
  108. package/src/cli/gallery-cli.ts +14 -2
  109. package/src/cli/gallery-fixtures/edit.ts +60 -0
  110. package/src/cli/gallery-fixtures/fs.ts +17 -17
  111. package/src/cli/gallery-fixtures/search.ts +4 -4
  112. package/src/cli/gc-cli.ts +939 -0
  113. package/src/cli/usage-cli.ts +20 -1
  114. package/src/cli/web-search-cli.ts +1 -1
  115. package/src/cli-commands.ts +1 -0
  116. package/src/collab/display-name.ts +13 -0
  117. package/src/collab/guest.ts +5 -1
  118. package/src/collab/host.ts +2 -13
  119. package/src/commands/gc.ts +46 -0
  120. package/src/commands/worktree.ts +6 -0
  121. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  122. package/src/config/model-discovery.ts +77 -8
  123. package/src/config/model-registry.ts +89 -11
  124. package/src/config/model-resolver.ts +39 -1
  125. package/src/config/models-config-schema.ts +41 -5
  126. package/src/config/models-config.ts +4 -1
  127. package/src/config/settings-schema.ts +130 -27
  128. package/src/config/settings.ts +216 -7
  129. package/src/cursor.ts +1 -1
  130. package/src/dap/config.ts +152 -8
  131. package/src/dap/session.ts +1 -1
  132. package/src/discovery/helpers.ts +3 -1
  133. package/src/edit/hashline/diff.ts +14 -3
  134. package/src/edit/hashline/execute.ts +42 -6
  135. package/src/edit/hashline/filesystem.ts +49 -8
  136. package/src/edit/index.ts +1 -0
  137. package/src/edit/modes/patch.ts +11 -1
  138. package/src/edit/renderer.ts +140 -13
  139. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  140. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  141. package/src/eval/agent-bridge.ts +26 -3
  142. package/src/eval/jl/runner.jl +7 -1
  143. package/src/eval/js/tool-bridge.ts +2 -2
  144. package/src/export/html/index.ts +1 -1
  145. package/src/export/html/template.js +3 -1
  146. package/src/export/html/tool-views.generated.js +20 -20
  147. package/src/extensibility/extensions/types.ts +22 -22
  148. package/src/extensibility/hooks/types.ts +11 -11
  149. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +10 -3
  150. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +31 -10
  151. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  152. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  153. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  154. package/src/internal-urls/docs-index.generated.txt +2 -2
  155. package/src/internal-urls/docs-index.ts +2 -3
  156. package/src/internal-urls/index.ts +1 -0
  157. package/src/internal-urls/registry-helpers.ts +19 -4
  158. package/src/internal-urls/router.ts +5 -3
  159. package/src/internal-urls/ssh-protocol.ts +367 -0
  160. package/src/internal-urls/types.ts +19 -2
  161. package/src/irc/bus.ts +11 -3
  162. package/src/lsp/index.ts +8 -1
  163. package/src/mcp/oauth-discovery.ts +20 -20
  164. package/src/mcp/tool-bridge.ts +32 -2
  165. package/src/mcp/transports/stdio.test.ts +20 -3
  166. package/src/mcp/transports/stdio.ts +45 -14
  167. package/src/memories/index.ts +1 -1
  168. package/src/modes/acp/acp-event-mapper.ts +2 -2
  169. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  170. package/src/modes/components/agent-hub.ts +3 -0
  171. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  172. package/src/modes/components/custom-editor.ts +90 -5
  173. package/src/modes/components/move-overlay.ts +282 -0
  174. package/src/modes/components/plan-review-overlay.ts +35 -35
  175. package/src/modes/components/plugin-selector.ts +6 -1
  176. package/src/modes/components/queue-mode-selector.ts +6 -1
  177. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  178. package/src/modes/components/session-selector.ts +11 -10
  179. package/src/modes/components/settings-defs.ts +14 -1
  180. package/src/modes/components/settings-selector.ts +196 -29
  181. package/src/modes/components/show-images-selector.ts +6 -1
  182. package/src/modes/components/status-line/component.ts +108 -28
  183. package/src/modes/components/status-line/segments.ts +5 -1
  184. package/src/modes/components/status-line/types.ts +2 -0
  185. package/src/modes/components/theme-selector.ts +6 -1
  186. package/src/modes/components/thinking-selector.ts +6 -1
  187. package/src/modes/components/tool-execution.ts +25 -9
  188. package/src/modes/components/tree-selector.ts +5 -5
  189. package/src/modes/controllers/command-controller.ts +140 -47
  190. package/src/modes/controllers/event-controller.ts +59 -3
  191. package/src/modes/controllers/input-controller.ts +70 -4
  192. package/src/modes/controllers/selector-controller.ts +15 -2
  193. package/src/modes/controllers/streaming-reveal.ts +17 -0
  194. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  195. package/src/modes/interactive-mode.ts +142 -59
  196. package/src/modes/internal-url-autocomplete.ts +17 -2
  197. package/src/modes/prompt-action-autocomplete.ts +3 -1
  198. package/src/modes/running-subagent-badge.ts +13 -0
  199. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  200. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  201. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  202. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  203. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  204. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  205. package/src/modes/theme/theme.ts +42 -15
  206. package/src/modes/types.ts +9 -1
  207. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  208. package/src/priority.json +15 -0
  209. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  210. package/src/prompts/advisor/system.md +21 -7
  211. package/src/prompts/agents/designer.md +1 -1
  212. package/src/prompts/agents/explore.md +1 -1
  213. package/src/prompts/agents/librarian.md +2 -2
  214. package/src/prompts/agents/plan.md +2 -2
  215. package/src/prompts/agents/reviewer.md +1 -1
  216. package/src/prompts/agents/task.md +2 -2
  217. package/src/prompts/system/active-repo-context.md +4 -0
  218. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  219. package/src/prompts/system/plan-mode-active.md +12 -3
  220. package/src/prompts/system/project-prompt.md +2 -2
  221. package/src/prompts/system/system-prompt.md +8 -6
  222. package/src/prompts/tools/bash.md +2 -2
  223. package/src/prompts/tools/checkpoint.md +1 -1
  224. package/src/prompts/tools/{find.md → glob.md} +1 -1
  225. package/src/prompts/tools/{search.md → grep.md} +3 -3
  226. package/src/prompts/tools/read.md +4 -2
  227. package/src/sdk.ts +98 -29
  228. package/src/session/agent-session.ts +828 -191
  229. package/src/session/session-history-format.ts +2 -2
  230. package/src/session/session-listing.ts +35 -2
  231. package/src/session/session-manager.ts +46 -0
  232. package/src/slash-commands/builtin-registry.ts +158 -22
  233. package/src/slash-commands/helpers/usage-report.ts +23 -2
  234. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  235. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  236. package/src/ssh/connection-manager.ts +12 -15
  237. package/src/ssh/file-transfer.ts +209 -0
  238. package/src/ssh/utils.ts +24 -0
  239. package/src/system-prompt.ts +60 -28
  240. package/src/task/executor.ts +96 -36
  241. package/src/task/index.ts +3 -3
  242. package/src/task/render.ts +14 -13
  243. package/src/task/worktree.ts +38 -1
  244. package/src/tiny/text.ts +49 -4
  245. package/src/tiny/worker.ts +3 -3
  246. package/src/tools/acp-bridge.ts +6 -1
  247. package/src/tools/bash.ts +2 -2
  248. package/src/tools/builtin-names.ts +26 -2
  249. package/src/tools/{find.ts → glob.ts} +48 -42
  250. package/src/tools/{search.ts → grep.ts} +311 -129
  251. package/src/tools/index.ts +13 -14
  252. package/src/tools/irc.ts +6 -2
  253. package/src/tools/path-utils.ts +64 -1
  254. package/src/tools/plan-mode-guard.ts +26 -13
  255. package/src/tools/read.ts +35 -6
  256. package/src/tools/render-utils.ts +14 -0
  257. package/src/tools/renderers.ts +15 -4
  258. package/src/tools/ssh.ts +17 -2
  259. package/src/tools/todo.ts +4 -26
  260. package/src/tools/write.ts +25 -9
  261. package/src/utils/active-repo-context.ts +143 -0
  262. package/src/utils/edit-mode.ts +19 -2
  263. package/src/utils/image-resize.ts +88 -7
  264. package/src/utils/lang-from-path.ts +3 -3
  265. package/src/utils/markit-cache.ts +166 -0
  266. package/src/utils/markit.ts +86 -18
  267. package/src/utils/prompt-path.ts +3 -0
  268. package/src/utils/shell-snapshot.ts +1 -1
  269. package/src/utils/title-generator.ts +1 -1
  270. package/src/web/search/provider.ts +54 -34
  271. package/src/web/search/providers/duckduckgo.ts +140 -0
  272. package/src/web/search/providers/firecrawl.ts +144 -0
  273. package/src/web/search/providers/tinyfish.ts +159 -0
  274. package/src/web/search/providers/xai.ts +292 -0
  275. package/src/web/search/providers/zai.ts +142 -56
  276. package/src/web/search/types.ts +6 -2
@@ -0,0 +1,143 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import * as path from "node:path";
4
+
5
+ import { type GitRepository, repo } from "./git";
6
+
7
+ export interface ActiveRepoContext {
8
+ cwd: string;
9
+ repoRoot: string;
10
+ relativeRepoRoot: string;
11
+ source: "single-direct-child-repo";
12
+ }
13
+
14
+ function compareEntryNames(left: fs.Dirent, right: fs.Dirent): number {
15
+ if (left.name < right.name) return -1;
16
+ if (left.name > right.name) return 1;
17
+ return 0;
18
+ }
19
+
20
+ function buildContext(cwd: string, repoRoot: string): ActiveRepoContext {
21
+ const resolvedCwd = path.resolve(cwd);
22
+ const resolvedRepoRoot = path.resolve(repoRoot);
23
+ return {
24
+ cwd: resolvedCwd,
25
+ repoRoot: resolvedRepoRoot,
26
+ relativeRepoRoot: path.relative(resolvedCwd, resolvedRepoRoot),
27
+ source: "single-direct-child-repo",
28
+ };
29
+ }
30
+
31
+ async function resolveRepository(cwd: string): Promise<GitRepository | null> {
32
+ try {
33
+ return await repo.resolve(cwd);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function resolveRepositorySync(cwd: string): GitRepository | null {
40
+ try {
41
+ return repo.resolveSync(cwd);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function readDirectChildren(cwd: string): Promise<fs.Dirent[]> {
48
+ try {
49
+ const entries = await fsPromises.readdir(cwd, { withFileTypes: true });
50
+ entries.sort(compareEntryNames);
51
+ return entries;
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function readDirectChildrenSync(cwd: string): fs.Dirent[] {
58
+ try {
59
+ const entries = fs.readdirSync(cwd, { withFileTypes: true });
60
+ entries.sort(compareEntryNames);
61
+ return entries;
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ async function resolveDirectChildDirectory(cwd: string, entry: fs.Dirent): Promise<string | null> {
68
+ const childPath = path.join(cwd, entry.name);
69
+ if (entry.isDirectory()) return childPath;
70
+ if (!entry.isSymbolicLink()) return null;
71
+ try {
72
+ const stat = await fsPromises.stat(childPath);
73
+ return stat.isDirectory() ? childPath : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function resolveDirectChildDirectorySync(cwd: string, entry: fs.Dirent): string | null {
80
+ const childPath = path.join(cwd, entry.name);
81
+ if (entry.isDirectory()) return childPath;
82
+ if (!entry.isSymbolicLink()) return null;
83
+ try {
84
+ const stat = fs.statSync(childPath);
85
+ return stat.isDirectory() ? childPath : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function hasGitMarker(childPath: string): Promise<boolean> {
92
+ try {
93
+ const stat = await fsPromises.stat(path.join(childPath, ".git"));
94
+ return stat.isDirectory() || stat.isFile();
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function hasGitMarkerSync(childPath: string): boolean {
101
+ try {
102
+ const stat = fs.statSync(path.join(childPath, ".git"));
103
+ return stat.isDirectory() || stat.isFile();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ async function findSingleDirectChildRepo(cwd: string): Promise<ActiveRepoContext | null> {
110
+ let context: ActiveRepoContext | null = null;
111
+ for (const entry of await readDirectChildren(cwd)) {
112
+ const childPath = await resolveDirectChildDirectory(cwd, entry);
113
+ if (!childPath) continue;
114
+ if (!(await hasGitMarker(childPath))) continue;
115
+ if (context) return null;
116
+ context = buildContext(cwd, childPath);
117
+ }
118
+ return context;
119
+ }
120
+
121
+ function findSingleDirectChildRepoSync(cwd: string): ActiveRepoContext | null {
122
+ let context: ActiveRepoContext | null = null;
123
+ for (const entry of readDirectChildrenSync(cwd)) {
124
+ const childPath = resolveDirectChildDirectorySync(cwd, entry);
125
+ if (!childPath) continue;
126
+ if (!hasGitMarkerSync(childPath)) continue;
127
+ if (context) return null;
128
+ context = buildContext(cwd, childPath);
129
+ }
130
+ return context;
131
+ }
132
+
133
+ export async function resolveActiveRepoContext(cwd: string): Promise<ActiveRepoContext | null> {
134
+ const resolvedCwd = path.resolve(cwd);
135
+ if (await resolveRepository(resolvedCwd)) return null;
136
+ return findSingleDirectChildRepo(resolvedCwd);
137
+ }
138
+
139
+ export function resolveActiveRepoContextSync(cwd: string): ActiveRepoContext | null {
140
+ const resolvedCwd = path.resolve(cwd);
141
+ if (resolveRepositorySync(resolvedCwd)) return null;
142
+ return findSingleDirectChildRepoSync(resolvedCwd);
143
+ }
@@ -1,4 +1,4 @@
1
- import { $env } from "@oh-my-pi/pi-utils";
1
+ import { $env, $flag } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  export type EditMode = "replace" | "patch" | "hashline" | "apply_patch";
4
4
 
@@ -13,6 +13,19 @@ const EDIT_MODE_IDS = {
13
13
 
14
14
  export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[];
15
15
 
16
+ const HASHLINE_EXCLUDED_MODEL_MODES: Array<{ pattern: string; mode: EditMode }> = [
17
+ { pattern: "kimi", mode: "replace" },
18
+ ];
19
+
20
+ function resolveHashlineExcludedModelMode(model: string | undefined): EditMode | null {
21
+ if (!model) return null;
22
+ const modelLower = model.toLowerCase();
23
+ for (const entry of HASHLINE_EXCLUDED_MODEL_MODES) {
24
+ if (modelLower.includes(entry.pattern)) return entry.mode;
25
+ }
26
+ return null;
27
+ }
28
+
16
29
  export function normalizeEditMode(mode?: string | null): EditMode | undefined {
17
30
  if (!mode) return undefined;
18
31
  return EDIT_MODE_IDS[mode as keyof typeof EDIT_MODE_IDS];
@@ -37,5 +50,9 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode {
37
50
  if (envMode) return envMode;
38
51
 
39
52
  const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? ""));
40
- return settingsMode ?? DEFAULT_EDIT_MODE;
53
+ const mode = settingsMode ?? DEFAULT_EDIT_MODE;
54
+ if (mode === "hashline" && !$flag("PI_STRICT_EDIT_MODE")) {
55
+ return resolveHashlineExcludedModelMode(activeModel) ?? mode;
56
+ }
57
+ return mode;
41
58
  }
@@ -18,6 +18,7 @@ export interface ResizedImage {
18
18
  width: number;
19
19
  height: number;
20
20
  wasResized: boolean;
21
+ decodeFailed?: boolean;
21
22
  get data(): string;
22
23
  }
23
24
 
@@ -42,6 +43,83 @@ const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
42
43
  minDimension: DEFAULT_MIN_DIMENSION,
43
44
  };
44
45
 
46
+ interface ImageHeaderDimensions {
47
+ width: number;
48
+ height: number;
49
+ mimeType: string;
50
+ }
51
+
52
+ function readUint16BE(buffer: Uint8Array, offset: number): number {
53
+ return (buffer[offset] << 8) | buffer[offset + 1];
54
+ }
55
+
56
+ function readUint32BE(buffer: Uint8Array, offset: number): number {
57
+ return ((buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]) >>> 0;
58
+ }
59
+
60
+ function readPngHeaderDimensions(buffer: Uint8Array): ImageHeaderDimensions | undefined {
61
+ if (buffer.length < 24) return undefined;
62
+ if (
63
+ buffer[0] !== 0x89 ||
64
+ buffer[1] !== 0x50 ||
65
+ buffer[2] !== 0x4e ||
66
+ buffer[3] !== 0x47 ||
67
+ buffer[4] !== 0x0d ||
68
+ buffer[5] !== 0x0a ||
69
+ buffer[6] !== 0x1a ||
70
+ buffer[7] !== 0x0a
71
+ ) {
72
+ return undefined;
73
+ }
74
+ if (readUint32BE(buffer, 8) !== 13) return undefined;
75
+ if (buffer[12] !== 0x49 || buffer[13] !== 0x48 || buffer[14] !== 0x44 || buffer[15] !== 0x52) return undefined;
76
+ const width = readUint32BE(buffer, 16);
77
+ const height = readUint32BE(buffer, 20);
78
+ if (width === 0 || height === 0) return undefined;
79
+ return { width, height, mimeType: "image/png" };
80
+ }
81
+
82
+ function isJpegStartOfFrame(marker: number): boolean {
83
+ return (
84
+ (marker >= 0xc0 && marker <= 0xc3) ||
85
+ (marker >= 0xc5 && marker <= 0xc7) ||
86
+ (marker >= 0xc9 && marker <= 0xcb) ||
87
+ (marker >= 0xcd && marker <= 0xcf)
88
+ );
89
+ }
90
+
91
+ function readJpegHeaderDimensions(buffer: Uint8Array): ImageHeaderDimensions | undefined {
92
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return undefined;
93
+ let offset = 2;
94
+ while (offset + 3 < buffer.length) {
95
+ if (buffer[offset] !== 0xff) {
96
+ offset++;
97
+ continue;
98
+ }
99
+ while (offset < buffer.length && buffer[offset] === 0xff) offset++;
100
+ if (offset >= buffer.length) return undefined;
101
+ const marker = buffer[offset++];
102
+ if (marker === 0xd9 || marker === 0xda) return undefined;
103
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
104
+ if (offset + 1 >= buffer.length) return undefined;
105
+ const segmentLength = readUint16BE(buffer, offset);
106
+ if (segmentLength < 2) return undefined;
107
+ if (isJpegStartOfFrame(marker)) {
108
+ if (offset + 7 >= buffer.length) return undefined;
109
+ const height = readUint16BE(buffer, offset + 3);
110
+ const width = readUint16BE(buffer, offset + 5);
111
+ if (width === 0 || height === 0) return undefined;
112
+ return { width, height, mimeType: "image/jpeg" };
113
+ }
114
+ offset += segmentLength;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function readImageHeaderDimensions(buffer: Uint8Array): ImageHeaderDimensions | undefined {
120
+ return readPngHeaderDimensions(buffer) ?? readJpegHeaderDimensions(buffer);
121
+ }
122
+
45
123
  /**
46
124
  * Read `OMP_NO_WEBP` per-call so runtime toggles take effect.
47
125
  * Only `"1"` and `"true"` (case-insensitive) enable exclusion — an empty string
@@ -298,21 +376,24 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
298
376
  },
299
377
  };
300
378
  } catch {
379
+ const headerDimensions = readImageHeaderDimensions(inputBuffer);
380
+ const fallbackMimeType = img.mimeType ?? headerDimensions?.mimeType ?? "application/octet-stream";
301
381
  // Bun.Image rejected the input — we cannot decode/re-encode it.
302
- // When the caller demanded WebP exclusion AND the original is WebP,
382
+ // When the caller demanded WebP exclusion AND the source might be WebP,
303
383
  // returning the original buffer would silently violate that contract,
304
384
  // so surface an explicit error instead.
305
- if (excludeWebP && (img.mimeType === "image/webp" || !img.mimeType)) {
385
+ if (excludeWebP && (fallbackMimeType === "image/webp" || (!img.mimeType && !headerDimensions))) {
306
386
  throw new Error("resizeImage: failed to decode image and cannot honor excludeWebP for a WebP source");
307
387
  }
308
388
  return {
309
389
  buffer: inputBuffer,
310
- mimeType: img.mimeType,
311
- originalWidth: 0,
312
- originalHeight: 0,
313
- width: 0,
314
- height: 0,
390
+ mimeType: fallbackMimeType,
391
+ originalWidth: headerDimensions?.width ?? 0,
392
+ originalHeight: headerDimensions?.height ?? 0,
393
+ width: headerDimensions?.width ?? 0,
394
+ height: headerDimensions?.height ?? 0,
315
395
  wasResized: false,
396
+ decodeFailed: true,
316
397
  get data() {
317
398
  return img.data;
318
399
  },
@@ -202,9 +202,6 @@ function lspExtensionKey(filePath: string): string {
202
202
  * Language id for syntax highlighting and UI (icons, read tool), or undefined if unknown.
203
203
  */
204
204
  export function getLanguageFromPath(filePath: string): string | undefined {
205
- const pair = EXTENSION_LANG[themeExtensionKey(filePath)];
206
- if (pair) return pair[0];
207
-
208
205
  const baseName = path.basename(filePath).toLowerCase();
209
206
  if (baseName.startsWith(".env.")) return "env";
210
207
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
@@ -214,6 +211,9 @@ export function getLanguageFromPath(filePath: string): string | undefined {
214
211
  if (baseName === "justfile") return "just";
215
212
  if (baseName === "cmakelists.txt") return "cmake";
216
213
 
214
+ const pair = EXTENSION_LANG[themeExtensionKey(filePath)];
215
+ if (pair) return pair[0];
216
+
217
217
  return undefined;
218
218
  }
219
219
 
@@ -0,0 +1,166 @@
1
+ import type { Stats } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { getDocumentConversionCacheDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
5
+ import packageJson from "../../package.json" with { type: "json" };
6
+
7
+ /**
8
+ * Cache schema/format revision. Bumping it changes the on-disk key prefix
9
+ * (`v<N>-...`), so old entries become unreachable and are pruned naturally.
10
+ * Bump when the cache *file* shape changes (entry JSON layout, key scheme).
11
+ *
12
+ * Converter *output* changes are handled separately: the package version is
13
+ * folded into the key (see {@link markitConversionCacheKey}), so any release
14
+ * that ships new markdown from `src/markit/converters/*` auto-invalidates the
15
+ * cache without a manual bump here.
16
+ */
17
+ export const MARKIT_CONVERSION_CACHE_VERSION = 1;
18
+ export const MAX_MARKIT_CONVERSION_CACHE_BYTES = 256 * 1024 * 1024;
19
+ /** `.tmp` files older than this are treated as orphaned writes and swept. */
20
+ const TMP_ORPHAN_MAX_AGE_MS = 5 * 60 * 1000;
21
+ export type MarkitConversionCacheStatus = "hit" | "miss" | "skipped";
22
+
23
+ export type MarkitConversionCacheReadResult = { status: "hit"; content: string } | { status: "miss" };
24
+
25
+ interface MarkitConversionCacheEntry {
26
+ version: number;
27
+ content: string;
28
+ }
29
+
30
+ export function markitConversionCacheKey(bytes: Uint8Array, extension: string): string {
31
+ const normalizedExtension = extension.trim().toLowerCase().replace(/^\.+/, "") || "bin";
32
+ const safeExtension = normalizedExtension.replace(/[^a-z0-9]+/g, "_") || "bin";
33
+ const safeVersion = packageJson.version.replace(/[^a-z0-9]+/gi, "_");
34
+ const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
35
+ return `v${MARKIT_CONVERSION_CACHE_VERSION}-${safeVersion}-${safeExtension}-${digest}`;
36
+ }
37
+
38
+ function cacheEntryPath(key: string): string {
39
+ return path.join(getDocumentConversionCacheDir(), `${key}.json`);
40
+ }
41
+
42
+ function errorMessage(error: unknown): string {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ function parseCacheEntry(raw: string): MarkitConversionCacheEntry | null {
47
+ const parsed: unknown = JSON.parse(raw);
48
+ if (typeof parsed !== "object" || parsed === null) return null;
49
+ if (!("version" in parsed) || parsed.version !== MARKIT_CONVERSION_CACHE_VERSION) return null;
50
+ if (!("content" in parsed) || typeof parsed.content !== "string" || parsed.content.length === 0) return null;
51
+ return { version: MARKIT_CONVERSION_CACHE_VERSION, content: parsed.content };
52
+ }
53
+
54
+ export async function readMarkitConversionCache(key: string): Promise<MarkitConversionCacheReadResult> {
55
+ const target = cacheEntryPath(key);
56
+ let raw: string;
57
+ try {
58
+ raw = await Bun.file(target).text();
59
+ } catch (error) {
60
+ if (!isEnoent(error)) {
61
+ logger.debug("document conversion cache read failed", { error: errorMessage(error) });
62
+ }
63
+ return { status: "miss" };
64
+ }
65
+
66
+ let entry: MarkitConversionCacheEntry | null;
67
+ try {
68
+ entry = parseCacheEntry(raw);
69
+ } catch (error) {
70
+ logger.debug("document conversion cache read failed", { error: errorMessage(error) });
71
+ entry = null;
72
+ }
73
+
74
+ if (!entry) {
75
+ await fs.rm(target, { force: true }).catch(() => undefined);
76
+ return { status: "miss" };
77
+ }
78
+
79
+ return { status: "hit", content: entry.content };
80
+ }
81
+
82
+ export async function pruneMarkitConversionCache(cacheDir: string): Promise<void> {
83
+ let names: string[];
84
+ try {
85
+ names = await fs.readdir(cacheDir);
86
+ } catch (error) {
87
+ if (!isEnoent(error)) {
88
+ logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
89
+ }
90
+ return;
91
+ }
92
+
93
+ const now = Date.now();
94
+ // Eviction is FIFO by mtime (not LRU): reads do not bump mtime, so a hot
95
+ // entry written long ago is evicted before a cold recent miss. The cap is a
96
+ // coarse disk-footprint safety valve, so the cheaper policy is intentional.
97
+ const entries: { path: string; size: number; mtimeMs: number }[] = [];
98
+ let totalBytes = 0;
99
+ for (const name of names) {
100
+ const entryPath = path.join(cacheDir, name);
101
+ let stat: Stats;
102
+ try {
103
+ stat = await fs.stat(entryPath);
104
+ } catch (error) {
105
+ if (!isEnoent(error)) {
106
+ logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
107
+ }
108
+ continue;
109
+ }
110
+ if (!stat.isFile()) continue;
111
+
112
+ // Sweep orphaned `.tmp` files left by a crash/SIGKILL between writeFile
113
+ // and rename; they never become `.json` entries, so the size cap would
114
+ // otherwise never see them.
115
+ if (name.endsWith(".tmp")) {
116
+ if (now - stat.mtimeMs > TMP_ORPHAN_MAX_AGE_MS) {
117
+ await fs.rm(entryPath, { force: true }).catch(() => undefined);
118
+ }
119
+ continue;
120
+ }
121
+
122
+ if (!name.endsWith(".json")) continue;
123
+ entries.push({ path: entryPath, size: stat.size, mtimeMs: stat.mtimeMs });
124
+ totalBytes += stat.size;
125
+ }
126
+
127
+ if (totalBytes <= MAX_MARKIT_CONVERSION_CACHE_BYTES) return;
128
+
129
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
130
+ for (const entry of entries) {
131
+ if (totalBytes <= MAX_MARKIT_CONVERSION_CACHE_BYTES) break;
132
+ try {
133
+ await fs.rm(entry.path, { force: true });
134
+ totalBytes -= entry.size;
135
+ } catch (error) {
136
+ if (!isEnoent(error)) {
137
+ logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ export async function writeMarkitConversionCache(key: string, content: string): Promise<void> {
144
+ const cacheDir = getDocumentConversionCacheDir();
145
+ const target = path.join(cacheDir, `${key}.json`);
146
+ // The random suffix keeps concurrent writers (same pid + same ms) from
147
+ // colliding on one temp path before the atomic rename.
148
+ const tempPath = path.join(cacheDir, `${key}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`);
149
+ const payload = JSON.stringify({ version: MARKIT_CONVERSION_CACHE_VERSION, content });
150
+ try {
151
+ await fs.mkdir(cacheDir, { recursive: true });
152
+ await Bun.write(tempPath, payload);
153
+ await fs.rename(tempPath, target);
154
+ } catch (error) {
155
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
156
+ logger.debug("document conversion cache write failed", { error: errorMessage(error) });
157
+ return;
158
+ }
159
+
160
+ // Prune is just GC: the entry is already on disk under its final name, so
161
+ // fire-and-forget rather than make the caller wait on a readdir + N×stat
162
+ // sweep on every miss (the slow path the cache exists to amortise).
163
+ void pruneMarkitConversionCache(cacheDir).catch(error => {
164
+ logger.debug("document conversion cache prune failed", { error: errorMessage(error) });
165
+ });
166
+ }
@@ -1,12 +1,20 @@
1
+ import * as path from "node:path";
1
2
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
2
- import type { Markit, StreamInfo } from "../markit";
3
+ import type { ConversionResult, Markit, StreamInfo } from "../markit";
3
4
  import { ToolAbortError } from "../tools/tool-errors";
5
+ import {
6
+ type MarkitConversionCacheStatus,
7
+ markitConversionCacheKey,
8
+ readMarkitConversionCache,
9
+ writeMarkitConversionCache,
10
+ } from "./markit-cache";
4
11
  import { loadEmbeddedMupdfWasm } from "./mupdf-wasm-embed";
5
12
 
6
13
  export interface MarkitConversionResult {
7
14
  content: string;
8
15
  ok: boolean;
9
16
  error?: string;
17
+ cache?: MarkitConversionCacheStatus;
10
18
  }
11
19
 
12
20
  export interface MarkitFileConversionOptions {
@@ -103,41 +111,101 @@ function finalizeConversion(markdown?: string): MarkitConversionResult {
103
111
  return { content: "", ok: false, error: "Conversion produced no output" };
104
112
  }
105
113
 
106
- export async function convertFileWithMarkit(
107
- filePath: string,
114
+ function toBuffer(bytes: Uint8Array): Buffer {
115
+ return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
116
+ }
117
+
118
+ function throwIfAborted(signal?: AbortSignal): void {
119
+ if (signal?.aborted) throw new ToolAbortError();
120
+ }
121
+
122
+ async function runCachedBufferConversion(
123
+ bytes: Uint8Array,
124
+ streamInfo: StreamInfo,
108
125
  signal?: AbortSignal,
109
- options?: MarkitFileConversionOptions,
126
+ cacheEnabled = true,
110
127
  ): Promise<MarkitConversionResult> {
111
- const extra = options?.imageDir ? { imageDir: options.imageDir } : undefined;
128
+ const cacheKey = cacheEnabled
129
+ ? markitConversionCacheKey(bytes, streamInfo.extension ?? streamInfo.mimetype ?? ".bin")
130
+ : undefined;
131
+
132
+ if (cacheKey) {
133
+ throwIfAborted(signal);
134
+ const cached = await readMarkitConversionCache(cacheKey);
135
+ throwIfAborted(signal);
136
+ if (cached.status === "hit") {
137
+ return { content: cached.content, ok: true, cache: "hit" };
138
+ }
139
+ }
140
+
141
+ throwIfAborted(signal);
142
+ let result: ConversionResult;
112
143
  try {
113
- const result = await runMarkitConversion(markit => markit.convertFile(filePath, extra), signal);
114
- return finalizeConversion(result.markdown);
144
+ result = await runMarkitConversion(markit => markit.convert(toBuffer(bytes), streamInfo), signal);
115
145
  } catch (error) {
116
146
  if (error instanceof ToolAbortError) {
117
147
  throw error;
118
148
  }
119
- return { content: "", ok: false, error: normalizeError(error) };
149
+ return { content: "", ok: false, error: normalizeError(error), cache: cacheEnabled ? "miss" : "skipped" };
150
+ }
151
+
152
+ const finalized = finalizeConversion(result.markdown);
153
+ if (finalized.ok && cacheKey) {
154
+ await writeMarkitConversionCache(cacheKey, finalized.content);
155
+ }
156
+ return { ...finalized, cache: cacheEnabled ? "miss" : "skipped" };
157
+ }
158
+
159
+ export async function convertFileWithMarkit(
160
+ filePath: string,
161
+ signal?: AbortSignal,
162
+ options?: MarkitFileConversionOptions,
163
+ ): Promise<MarkitConversionResult> {
164
+ if (options?.imageDir) {
165
+ // Image extraction writes files into imageDir as a side effect; a
166
+ // markdown-only cache hit would leave the directory missing members, so
167
+ // this path stays uncached.
168
+ try {
169
+ const result = await runMarkitConversion(
170
+ markit => markit.convertFile(filePath, { imageDir: options.imageDir }),
171
+ signal,
172
+ );
173
+ return { ...finalizeConversion(result.markdown), cache: "skipped" };
174
+ } catch (error) {
175
+ if (error instanceof ToolAbortError) {
176
+ throw error;
177
+ }
178
+ return { content: "", ok: false, error: normalizeError(error), cache: "skipped" };
179
+ }
120
180
  }
181
+
182
+ throwIfAborted(signal);
183
+ let bytes: Uint8Array;
184
+ try {
185
+ bytes = await untilAborted(signal, () => Bun.file(filePath).bytes());
186
+ } catch (error) {
187
+ if (error instanceof ToolAbortError) throw error;
188
+ if (error instanceof Error && error.name === "AbortError") throw new ToolAbortError();
189
+ return { content: "", ok: false, error: normalizeError(error), cache: "miss" };
190
+ }
191
+ const streamInfo: StreamInfo = {
192
+ localPath: filePath,
193
+ extension: path.extname(filePath).toLowerCase(),
194
+ filename: path.basename(filePath),
195
+ };
196
+ return runCachedBufferConversion(bytes, streamInfo, signal, true);
121
197
  }
122
198
 
123
199
  export async function convertBufferWithMarkit(
124
200
  buffer: Uint8Array,
125
201
  extension: string,
126
202
  signal?: AbortSignal,
203
+ options?: { useCache?: boolean },
127
204
  ): Promise<MarkitConversionResult> {
128
205
  const normalizedExtension = normalizeExtension(extension);
129
206
  const streamInfo: StreamInfo = {
130
207
  extension: normalizedExtension,
131
208
  filename: `input${normalizedExtension}`,
132
209
  };
133
-
134
- try {
135
- const result = await runMarkitConversion(markit => markit.convert(Buffer.from(buffer), streamInfo), signal);
136
- return finalizeConversion(result.markdown);
137
- } catch (error) {
138
- if (error instanceof ToolAbortError) {
139
- throw error;
140
- }
141
- return { content: "", ok: false, error: normalizeError(error) };
142
- }
210
+ return runCachedBufferConversion(buffer, streamInfo, signal, options?.useCache ?? true);
143
211
  }
@@ -0,0 +1,3 @@
1
+ export function normalizePromptPath(value: string): string {
2
+ return value.replace(/\\/g, "/");
3
+ }
@@ -17,7 +17,7 @@ const SNAPSHOT_TIMEOUT_MS = 2_000;
17
17
  /**
18
18
  * Characters that force brush's primitive alias expander down a path it does
19
19
  * not implement. brush-core resolves aliases via `value.split_ascii_whitespace()`
20
- * (`crates/brush-core-vendored/src/interp.rs:1500`, tracking
20
+ * (`crates/vendor/brush-core/src/interp.rs:1500`, tracking
21
21
  * https://github.com/reubeno/brush/issues/57): the resulting pieces are dropped
22
22
  * into argv verbatim instead of going through the shell parser. Any alias body
23
23
  * containing subshells `(...)`, pipes `|`, redirections `<` `>`, separators
@@ -246,7 +246,7 @@ export async function generateTitleOnline(
246
246
  return null;
247
247
  }
248
248
 
249
- const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
249
+ const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content), firstMessage);
250
250
 
251
251
  if (!title) {
252
252
  logger.debug("title-generator: no title returned", {