@bahulam/code 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +93 -0
  2. package/package.json +56 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +223 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +312 -0
  136. package/src/commands/agent.mjs +221 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +202 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/model-catalog.mjs +57 -0
  143. package/src/config/settings-loader.mjs +45 -0
  144. package/src/config/settings.mjs +132 -0
  145. package/src/context/ast-parser.mjs +298 -0
  146. package/src/context/bm25.mjs +85 -0
  147. package/src/context/prose-chunker.mjs +255 -0
  148. package/src/context/retriever.mjs +425 -0
  149. package/src/context/skeleton.mjs +134 -0
  150. package/src/context/symbol-indexer.mjs +375 -0
  151. package/src/core/agent-history.mjs +111 -0
  152. package/src/core/agent-loop.mjs +486 -0
  153. package/src/core/approval-log.mjs +145 -0
  154. package/src/core/approval.mjs +700 -0
  155. package/src/core/attachments.mjs +666 -0
  156. package/src/core/backend-url.mjs +68 -0
  157. package/src/core/bundled-runtime.mjs +418 -0
  158. package/src/core/cache-control.mjs +92 -0
  159. package/src/core/cache.mjs +105 -0
  160. package/src/core/callback-client.mjs +180 -0
  161. package/src/core/checkpoints.mjs +142 -0
  162. package/src/core/compact-history.mjs +127 -0
  163. package/src/core/context-envelope.mjs +54 -0
  164. package/src/core/context-manager.mjs +198 -0
  165. package/src/core/error-guidance.mjs +331 -0
  166. package/src/core/file-diff.mjs +217 -0
  167. package/src/core/headless.mjs +460 -0
  168. package/src/core/hooks-manager.mjs +87 -0
  169. package/src/core/jsonl-writer.mjs +449 -0
  170. package/src/core/local-agent.mjs +538 -0
  171. package/src/core/local-store.mjs +836 -0
  172. package/src/core/mode-selector.mjs +51 -0
  173. package/src/core/output-filter.mjs +177 -0
  174. package/src/core/paths.mjs +190 -0
  175. package/src/core/policy-resolver.mjs +156 -0
  176. package/src/core/pricing.mjs +336 -0
  177. package/src/core/project-artifacts.mjs +39 -0
  178. package/src/core/project-context-loader.mjs +139 -0
  179. package/src/core/providers.mjs +219 -0
  180. package/src/core/rate-limit-display.mjs +121 -0
  181. package/src/core/rate-limiter.mjs +119 -0
  182. package/src/core/resume-mode.mjs +192 -0
  183. package/src/core/risk-tier.mjs +388 -0
  184. package/src/core/safety.mjs +260 -0
  185. package/src/core/scheduler.mjs +173 -0
  186. package/src/core/session-manager.mjs +360 -0
  187. package/src/core/session.mjs +143 -0
  188. package/src/core/settings-sync.mjs +85 -0
  189. package/src/core/stagnation.mjs +57 -0
  190. package/src/core/stream-client.mjs +957 -0
  191. package/src/core/streaming.mjs +182 -0
  192. package/src/core/system-prompt.mjs +140 -0
  193. package/src/core/tasks.mjs +196 -0
  194. package/src/core/tool-executor.mjs +2231 -0
  195. package/src/core/trust.mjs +160 -0
  196. package/src/core/work-scope.mjs +248 -0
  197. package/src/hooks/engine.mjs +162 -0
  198. package/src/mcp/client.mjs +253 -0
  199. package/src/mcp/transport-shttp.mjs +130 -0
  200. package/src/mcp/transport-sse.mjs +131 -0
  201. package/src/mcp/transport-ws.mjs +134 -0
  202. package/src/onboarding/preflight.mjs +374 -0
  203. package/src/permissions/checker.mjs +57 -0
  204. package/src/permissions/command-classifier.mjs +700 -0
  205. package/src/permissions/injection-check.mjs +60 -0
  206. package/src/permissions/path-check.mjs +102 -0
  207. package/src/permissions/prompt.mjs +73 -0
  208. package/src/permissions/sandbox.mjs +112 -0
  209. package/src/plugins/loader.mjs +138 -0
  210. package/src/skills/installer.mjs +188 -0
  211. package/src/skills/loader.mjs +252 -0
  212. package/src/skills/runner.mjs +55 -0
  213. package/src/state/orbit.mjs +263 -0
  214. package/src/state/verbosity.mjs +99 -0
  215. package/src/telemetry/index.mjs +122 -0
  216. package/src/terminal/agents.mjs +353 -0
  217. package/src/terminal/analytics.mjs +292 -0
  218. package/src/terminal/ansi.mjs +695 -0
  219. package/src/terminal/init.mjs +145 -0
  220. package/src/terminal/main.mjs +310 -0
  221. package/src/terminal/repl-ask-form.mjs +120 -0
  222. package/src/terminal/repl-explore.mjs +44 -0
  223. package/src/terminal/repl-format.mjs +317 -0
  224. package/src/terminal/repl-model-form.mjs +132 -0
  225. package/src/terminal/repl-render.mjs +833 -0
  226. package/src/terminal/repl-resume.mjs +640 -0
  227. package/src/terminal/repl-state.mjs +120 -0
  228. package/src/terminal/repl-utils.mjs +34 -0
  229. package/src/terminal/repl.mjs +5032 -0
  230. package/src/terminal/skills.mjs +54 -0
  231. package/src/terminal/tool-display.mjs +392 -0
  232. package/src/tools/agent.mjs +137 -0
  233. package/src/tools/ask-user.mjs +61 -0
  234. package/src/tools/bash.mjs +231 -0
  235. package/src/tools/cron-create.mjs +120 -0
  236. package/src/tools/cron-delete.mjs +49 -0
  237. package/src/tools/cron-list.mjs +37 -0
  238. package/src/tools/edit.mjs +82 -0
  239. package/src/tools/enter-worktree.mjs +69 -0
  240. package/src/tools/exit-worktree.mjs +57 -0
  241. package/src/tools/glob.mjs +117 -0
  242. package/src/tools/grep.mjs +129 -0
  243. package/src/tools/lint.mjs +71 -0
  244. package/src/tools/ls.mjs +58 -0
  245. package/src/tools/lsp.mjs +115 -0
  246. package/src/tools/multi-edit.mjs +94 -0
  247. package/src/tools/notebook-edit.mjs +96 -0
  248. package/src/tools/project-overview.mjs +703 -0
  249. package/src/tools/read-mcp-resource.mjs +57 -0
  250. package/src/tools/read.mjs +138 -0
  251. package/src/tools/registry.mjs +116 -0
  252. package/src/tools/remote-trigger.mjs +84 -0
  253. package/src/tools/send-message.mjs +64 -0
  254. package/src/tools/skill.mjs +52 -0
  255. package/src/tools/test-runner.mjs +49 -0
  256. package/src/tools/todo-write.mjs +68 -0
  257. package/src/tools/tool-search.mjs +77 -0
  258. package/src/tools/web-fetch.mjs +65 -0
  259. package/src/tools/web-search.mjs +89 -0
  260. package/src/tools/write.mjs +55 -0
  261. package/src/ui/approval.mjs +510 -0
  262. package/src/ui/banner.mjs +232 -0
  263. package/src/ui/commands.mjs +537 -0
  264. package/src/ui/formatter.mjs +409 -0
  265. package/src/ui/icons.mjs +170 -0
  266. package/src/ui/input-dock.mjs +772 -0
  267. package/src/ui/markdown.mjs +278 -0
  268. package/src/ui/mission-report.mjs +296 -0
  269. package/src/ui/palette.mjs +189 -0
  270. package/src/ui/render-queue.mjs +500 -0
  271. package/src/ui/slash-commands.mjs +257 -0
  272. package/src/ui/spinner.mjs +116 -0
  273. package/src/ui/sub-agent.mjs +167 -0
  274. package/src/ui/term.mjs +174 -0
  275. package/src/ui/text-layout.mjs +127 -0
  276. package/src/ui/tool-card.mjs +740 -0
  277. package/src/ui/tool-details.mjs +504 -0
  278. package/src/ui/transcript-block.mjs +20 -0
@@ -0,0 +1,666 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
8
+ // Text-first document formats we can extract client-side and inline into the
9
+ // user turn as plain text — zero backend upload, zero tool call. PDFs go
10
+ // through pdf-parse; the rest are read as UTF-8.
11
+ const DOCUMENT_EXTENSIONS = new Set([
12
+ '.pdf',
13
+ '.txt', '.md', '.mdx', '.rst',
14
+ '.csv', '.tsv', '.log',
15
+ '.json', '.yaml', '.yml', '.toml', '.ini', '.env',
16
+ '.html', '.htm', '.xml',
17
+ ]);
18
+ const DEFAULT_MAX_IMAGE_BYTES = 8 * 1024 * 1024;
19
+ const DEFAULT_MAX_TURN_BYTES = 20 * 1024 * 1024;
20
+ // Per-document and per-turn caps for inlined document text. Keep well under
21
+ // context so multiple attachments still leave room for the actual work.
22
+ const DEFAULT_MAX_DOC_CHARS = 80_000;
23
+ const DEFAULT_MAX_TURN_DOC_CHARS = 200_000;
24
+ const DEFAULT_MAX_DOC_BYTES = 5 * 1024 * 1024;
25
+
26
+ function envInt(name, fallback) {
27
+ const raw = process.env[name];
28
+ if (!raw) return fallback;
29
+ const value = Number.parseInt(raw, 10);
30
+ return Number.isFinite(value) && value > 0 ? value : fallback;
31
+ }
32
+
33
+ function expandHome(filePath) {
34
+ if (filePath === '~') return os.homedir();
35
+ if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2));
36
+ return filePath;
37
+ }
38
+
39
+ export function resolveAttachmentPath(filePath, cwd = process.cwd()) {
40
+ const expanded = expandHome(String(filePath || '').trim());
41
+ return path.resolve(cwd, expanded);
42
+ }
43
+
44
+ function trimTrailingPunctuation(value) {
45
+ return String(value || '').replace(/[),.;:!?]+$/g, '');
46
+ }
47
+
48
+ function readQuoted(input, start) {
49
+ const quote = input[start];
50
+ let out = '';
51
+ let i = start + 1;
52
+ for (; i < input.length; i++) {
53
+ const ch = input[i];
54
+ if (ch === '\\' && i + 1 < input.length) {
55
+ out += input[i + 1];
56
+ i++;
57
+ continue;
58
+ }
59
+ if (ch === quote) return { value: out, end: i + 1 };
60
+ out += ch;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function readBare(input, start) {
66
+ let i = start;
67
+ while (i < input.length && !/\s/.test(input[i])) i++;
68
+ return { value: trimTrailingPunctuation(input.slice(start, i)), end: i };
69
+ }
70
+
71
+ function looksLikeImagePath(value) {
72
+ const ext = path.extname(String(value || '').toLowerCase());
73
+ return IMAGE_EXTENSIONS.has(ext);
74
+ }
75
+
76
+ export function parseImageReferences(input, { cwd = process.cwd() } = {}) {
77
+ const text = String(input || '');
78
+ const attachments = [];
79
+ let cleaned = '';
80
+ let i = 0;
81
+
82
+ while (i < text.length) {
83
+ if (text[i] !== '@') {
84
+ cleaned += text[i++];
85
+ continue;
86
+ }
87
+
88
+ const next = text[i + 1];
89
+ let parsed = null;
90
+ if (next === '"' || next === "'") {
91
+ parsed = readQuoted(text, i + 1);
92
+ } else if (next && !/\s/.test(next)) {
93
+ parsed = readBare(text, i + 1);
94
+ }
95
+
96
+ if (!parsed || (!looksLikeImagePath(parsed.value) && !isClipboardAlias(parsed.value))) {
97
+ cleaned += text[i++];
98
+ continue;
99
+ }
100
+
101
+ // @clipboard / @paste — resolve via OS clipboard, attach the resulting
102
+ // temp file path. Skipping (with a stderr note) on unsupported platforms
103
+ // or empty clipboards so the rest of the parse continues cleanly.
104
+ if (isClipboardAlias(parsed.value)) {
105
+ try {
106
+ const filePath = writeClipboardImageToTemp();
107
+ attachments.push({ raw: parsed.value, path: filePath, source: 'clipboard' });
108
+ } catch (err) {
109
+ try {
110
+ process.stderr.write(` ! @${parsed.value} skipped — ${err.message || String(err)}\n`);
111
+ } catch {}
112
+ }
113
+ i = parsed.end;
114
+ continue;
115
+ }
116
+
117
+ attachments.push({
118
+ raw: parsed.value,
119
+ path: resolveAttachmentPath(parsed.value, cwd),
120
+ });
121
+ i = parsed.end;
122
+ }
123
+
124
+ return {
125
+ instruction: cleaned.replace(/\s+/g, ' ').trim(),
126
+ references: attachments,
127
+ };
128
+ }
129
+
130
+ function sniffImage(buffer) {
131
+ if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
132
+ return { mime_type: 'image/png', ext: '.png' };
133
+ }
134
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
135
+ return { mime_type: 'image/jpeg', ext: '.jpg' };
136
+ }
137
+ if (buffer.length >= 10 && (buffer.subarray(0, 6).toString('ascii') === 'GIF87a' || buffer.subarray(0, 6).toString('ascii') === 'GIF89a')) {
138
+ return { mime_type: 'image/gif', ext: '.gif' };
139
+ }
140
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
141
+ return { mime_type: 'image/webp', ext: '.webp' };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function pngDimensions(buffer) {
147
+ if (buffer.length < 24) return {};
148
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
149
+ }
150
+
151
+ function gifDimensions(buffer) {
152
+ if (buffer.length < 10) return {};
153
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
154
+ }
155
+
156
+ function jpegDimensions(buffer) {
157
+ let offset = 2;
158
+ while (offset + 9 < buffer.length) {
159
+ if (buffer[offset] !== 0xff) break;
160
+ const marker = buffer[offset + 1];
161
+ const length = buffer.readUInt16BE(offset + 2);
162
+ if (length < 2) break;
163
+ if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
164
+ return {
165
+ height: buffer.readUInt16BE(offset + 5),
166
+ width: buffer.readUInt16BE(offset + 7),
167
+ };
168
+ }
169
+ offset += 2 + length;
170
+ }
171
+ return {};
172
+ }
173
+
174
+ function webpDimensions(buffer) {
175
+ if (buffer.length < 30) return {};
176
+ const chunk = buffer.subarray(12, 16).toString('ascii');
177
+ if (chunk === 'VP8X' && buffer.length >= 30) {
178
+ return {
179
+ width: 1 + buffer.readUIntLE(24, 3),
180
+ height: 1 + buffer.readUIntLE(27, 3),
181
+ };
182
+ }
183
+ return {};
184
+ }
185
+
186
+ function imageDimensions(buffer, mimeType) {
187
+ if (mimeType === 'image/png') return pngDimensions(buffer);
188
+ if (mimeType === 'image/jpeg') return jpegDimensions(buffer);
189
+ if (mimeType === 'image/gif') return gifDimensions(buffer);
190
+ if (mimeType === 'image/webp') return webpDimensions(buffer);
191
+ return {};
192
+ }
193
+
194
+ export function publicAttachmentMetadata(attachment) {
195
+ if (!attachment) return null;
196
+ return {
197
+ id: attachment.id,
198
+ kind: 'image',
199
+ source: attachment.source || 'local_file',
200
+ name: attachment.name,
201
+ path: attachment.path,
202
+ mime_type: attachment.mime_type,
203
+ bytes: attachment.bytes,
204
+ width: attachment.width || null,
205
+ height: attachment.height || null,
206
+ sha256: attachment.sha256,
207
+ optimized: Boolean(attachment.optimized),
208
+ };
209
+ }
210
+
211
+ function appleScriptString(value) {
212
+ return `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
213
+ }
214
+
215
+ function powershellString(value) {
216
+ return `'${String(value || '').replace(/'/g, "''")}'`;
217
+ }
218
+
219
+ function ensureClipboardDir(baseDir = os.tmpdir()) {
220
+ const dir = path.join(baseDir, 'kepler-clipboard-images');
221
+ fs.mkdirSync(dir, { recursive: true });
222
+ return dir;
223
+ }
224
+
225
+ function hasUsableFile(filePath) {
226
+ try {
227
+ return fs.statSync(filePath).isFile() && fs.statSync(filePath).size > 0;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ export function writeClipboardImageToTemp({
234
+ baseDir = os.tmpdir(),
235
+ runner = execFileSync,
236
+ platform = process.platform,
237
+ } = {}) {
238
+ const dir = ensureClipboardDir(baseDir);
239
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
240
+ const pngPath = path.join(dir, `clipboard-${stamp}.png`);
241
+
242
+ if (platform === 'win32') {
243
+ const script = [
244
+ 'Add-Type -AssemblyName System.Windows.Forms',
245
+ 'Add-Type -AssemblyName System.Drawing',
246
+ '$image = [System.Windows.Forms.Clipboard]::GetImage()',
247
+ "if ($null -eq $image) { throw 'Clipboard does not contain an image.' }",
248
+ `$out = ${powershellString(pngPath)}`,
249
+ '$image.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)',
250
+ 'Write-Output $out',
251
+ ].join('; ');
252
+
253
+ try {
254
+ const savedPath = String(runner('powershell.exe', ['-NoProfile', '-Sta', '-Command', script], {
255
+ encoding: 'utf-8',
256
+ stdio: 'pipe',
257
+ windowsHide: true,
258
+ }) || '').trim().split(/\r?\n/).pop().trim();
259
+ if (hasUsableFile(savedPath)) return savedPath;
260
+ if (hasUsableFile(pngPath)) return pngPath;
261
+ } catch (err) {
262
+ throw new Error(`Clipboard does not contain a readable image. Copy an image, or save it and use /attach <path>. ${err.message || err}`);
263
+ }
264
+ throw new Error('Clipboard image import did not produce an image file.');
265
+ }
266
+
267
+ if (platform !== 'darwin') {
268
+ throw new Error('Clipboard image import is currently supported on macOS and Windows. Save the image to a file and use /attach <path>.');
269
+ }
270
+
271
+ const tiffPath = path.join(dir, `clipboard-${stamp}.tiff`);
272
+
273
+ try {
274
+ runner('pngpaste', [pngPath], { stdio: 'pipe' });
275
+ if (hasUsableFile(pngPath)) return pngPath;
276
+ } catch {
277
+ // pngpaste is optional. Fall through to built-in macOS tools.
278
+ }
279
+
280
+ const script = [
281
+ 'try',
282
+ ' set imageData to the clipboard as «class PNGf»',
283
+ ` set outPath to ${appleScriptString(pngPath)}`,
284
+ ' set outFile to open for access POSIX file outPath with write permission',
285
+ ' set eof outFile to 0',
286
+ ' write imageData to outFile',
287
+ ' close access outFile',
288
+ ' return outPath',
289
+ 'on error',
290
+ ' try',
291
+ ' set imageData to the clipboard as «class TIFF»',
292
+ ` set outPath to ${appleScriptString(tiffPath)}`,
293
+ ' set outFile to open for access POSIX file outPath with write permission',
294
+ ' set eof outFile to 0',
295
+ ' write imageData to outFile',
296
+ ' close access outFile',
297
+ ' return outPath',
298
+ ' on error errMsg number errNum',
299
+ ' error "Clipboard does not contain a supported image." number errNum',
300
+ ' end try',
301
+ 'end try',
302
+ ];
303
+
304
+ let savedPath = '';
305
+ try {
306
+ savedPath = String(runner('osascript', script.flatMap(line => ['-e', line]), { encoding: 'utf-8', stdio: 'pipe' }) || '').trim();
307
+ } catch (err) {
308
+ throw new Error(`Clipboard does not contain a readable image. Copy an image, or save it and use /attach <path>. ${err.message || err}`);
309
+ }
310
+
311
+ if (savedPath.endsWith('.tiff')) {
312
+ try {
313
+ runner('sips', ['-s', 'format', 'png', savedPath, '--out', pngPath], { stdio: 'pipe' });
314
+ if (hasUsableFile(pngPath)) return pngPath;
315
+ } catch (err) {
316
+ throw new Error(`Clipboard image was TIFF but could not be converted to PNG: ${err.message || err}`);
317
+ }
318
+ }
319
+
320
+ if (hasUsableFile(savedPath)) return savedPath;
321
+ if (hasUsableFile(pngPath)) return pngPath;
322
+ throw new Error('Clipboard image import did not produce an image file.');
323
+ }
324
+
325
+ export function loadClipboardImageAttachment(options = {}) {
326
+ const filePath = writeClipboardImageToTemp(options);
327
+ const attachment = loadImageAttachment(filePath, options);
328
+ return { ...attachment, source: 'clipboard' };
329
+ }
330
+
331
+ export function attachmentSummaryLine(attachment) {
332
+ const dims = attachment.width && attachment.height ? `${attachment.width}x${attachment.height}` : 'unknown size';
333
+ const kb = Math.max(1, Math.round((attachment.bytes || 0) / 1024));
334
+ return `${attachment.name} ${dims} · ${kb} KB`;
335
+ }
336
+
337
+ export function loadImageAttachment(filePath, { cwd = process.cwd(), maxBytes = DEFAULT_MAX_IMAGE_BYTES } = {}) {
338
+ const resolved = resolveAttachmentPath(filePath, cwd);
339
+ const stat = fs.statSync(resolved);
340
+ if (!stat.isFile()) throw new Error(`Not a file: ${resolved}`);
341
+ if (stat.size > maxBytes) {
342
+ throw new Error(`Image exceeds ${Math.round(maxBytes / 1024 / 1024)} MB: ${resolved}`);
343
+ }
344
+
345
+ const buffer = fs.readFileSync(resolved);
346
+ const sniffed = sniffImage(buffer);
347
+ if (!sniffed) throw new Error(`Unsupported or invalid image file: ${resolved}`);
348
+
349
+ const ext = path.extname(resolved).toLowerCase();
350
+ if (ext && IMAGE_EXTENSIONS.has(ext) && ext !== sniffed.ext && !(ext === '.jpeg' && sniffed.ext === '.jpg')) {
351
+ throw new Error(`Image extension does not match file contents: ${resolved}`);
352
+ }
353
+
354
+ const dims = imageDimensions(buffer, sniffed.mime_type);
355
+ return {
356
+ id: `att_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
357
+ kind: 'image',
358
+ source: 'local_file',
359
+ name: path.basename(resolved),
360
+ path: resolved,
361
+ mime_type: sniffed.mime_type,
362
+ bytes: stat.size,
363
+ width: dims.width || null,
364
+ height: dims.height || null,
365
+ sha256: createHash('sha256').update(buffer).digest('hex'),
366
+ data_base64: buffer.toString('base64'),
367
+ optimized: false,
368
+ };
369
+ }
370
+
371
+ export function prepareImageAttachments(input, {
372
+ cwd = process.cwd(),
373
+ extraPaths = [],
374
+ maxImageBytes = envInt('KEPLER_VISION_MAX_IMAGE_BYTES', DEFAULT_MAX_IMAGE_BYTES),
375
+ maxTurnBytes = envInt('KEPLER_VISION_MAX_TURN_BYTES', DEFAULT_MAX_TURN_BYTES),
376
+ } = {}) {
377
+ const parsed = parseImageReferences(input, { cwd });
378
+ const paths = [
379
+ ...parsed.references.map(ref => ref.path),
380
+ ...extraPaths.map(p => resolveAttachmentPath(p, cwd)),
381
+ ];
382
+ const uniquePaths = [...new Set(paths)];
383
+ const attachments = uniquePaths.map(p => loadImageAttachment(p, { cwd, maxBytes: maxImageBytes }));
384
+ const totalBytes = attachments.reduce((sum, att) => sum + (att.bytes || 0), 0);
385
+ if (totalBytes > maxTurnBytes) {
386
+ throw new Error(`Attached images exceed ${Math.round(maxTurnBytes / 1024 / 1024)} MB per turn`);
387
+ }
388
+ return {
389
+ instruction: parsed.instruction || String(input || '').trim(),
390
+ attachments,
391
+ metadata: attachments.map(publicAttachmentMetadata),
392
+ };
393
+ }
394
+
395
+ // ── Document attachments (PRD-091 shared tools mirror) ──────────────────
396
+ //
397
+ // Same @path parser shape as images, but for text-first formats we can
398
+ // extract client-side and inline as text into the user turn. Zero upload,
399
+ // zero tool round-trip. PDFs go through pdf-parse; text formats are read
400
+ // as UTF-8 with a byte + char cap. Kept in this module so callers get one
401
+ // unified attachment pipeline.
402
+
403
+ function looksLikeDocumentPath(value) {
404
+ const ext = path.extname(String(value || '').toLowerCase());
405
+ return DOCUMENT_EXTENSIONS.has(ext);
406
+ }
407
+
408
+ // Special reference tokens (no path extension) — parser recognises them
409
+ // and resolves at parse time.
410
+ const CLIPBOARD_ALIASES = new Set(['clipboard', 'paste']);
411
+
412
+ function isClipboardAlias(value) {
413
+ return CLIPBOARD_ALIASES.has(String(value || '').trim().toLowerCase());
414
+ }
415
+
416
+ function looksLikeAttachment(value) {
417
+ return looksLikeImagePath(value) || looksLikeDocumentPath(value) || isClipboardAlias(value);
418
+ }
419
+
420
+ /**
421
+ * Parse @path references from `input` for BOTH images and documents.
422
+ * Returns a cleaned instruction (with @refs removed) plus separate
423
+ * image + document reference lists. Mirrors parseImageReferences but
424
+ * dispatches on extension.
425
+ */
426
+ export function parseAttachmentReferences(input, { cwd = process.cwd() } = {}) {
427
+ const text = String(input || '');
428
+ const images = [];
429
+ const documents = [];
430
+ let cleaned = '';
431
+ let i = 0;
432
+
433
+ while (i < text.length) {
434
+ if (text[i] !== '@') {
435
+ cleaned += text[i++];
436
+ continue;
437
+ }
438
+ const next = text[i + 1];
439
+ let parsed = null;
440
+ if (next === '"' || next === "'") {
441
+ parsed = readQuoted(text, i + 1);
442
+ } else if (next && !/\s/.test(next)) {
443
+ parsed = readBare(text, i + 1);
444
+ }
445
+ if (!parsed || !looksLikeAttachment(parsed.value)) {
446
+ cleaned += text[i++];
447
+ continue;
448
+ }
449
+
450
+ // @clipboard / @paste — resolve now via the OS clipboard helper. Side
451
+ // effect (writes a temp PNG) is fine at parse time because the parser
452
+ // is called once per turn, matching when the user typed @clipboard.
453
+ if (isClipboardAlias(parsed.value)) {
454
+ try {
455
+ const filePath = writeClipboardImageToTemp();
456
+ images.push({ raw: parsed.value, path: filePath, source: 'clipboard' });
457
+ } catch (err) {
458
+ // Emit a stderr note so the user knows why their @clipboard
459
+ // reference didn't attach anything, then drop it from the parse.
460
+ try {
461
+ process.stderr.write(` ! @${parsed.value} skipped — ${err.message || String(err)}\n`);
462
+ } catch {}
463
+ }
464
+ i = parsed.end;
465
+ continue;
466
+ }
467
+
468
+ const attachment = {
469
+ raw: parsed.value,
470
+ path: resolveAttachmentPath(parsed.value, cwd),
471
+ };
472
+ if (looksLikeImagePath(parsed.value)) images.push(attachment);
473
+ else documents.push(attachment);
474
+ i = parsed.end;
475
+ }
476
+
477
+ return {
478
+ instruction: cleaned.replace(/\s+/g, ' ').trim(),
479
+ images,
480
+ documents,
481
+ };
482
+ }
483
+
484
+ async function extractPdfText(filePath, { maxChars }) {
485
+ // pdf-parse is a CJS module. Dynamic import so ESM callers stay clean and
486
+ // we don't pay the parse cost on startup for CLIs that never touch PDFs.
487
+ const { default: pdfParse } = await import('pdf-parse');
488
+ const buffer = fs.readFileSync(filePath);
489
+ const parsed = await pdfParse(buffer);
490
+ const raw = String(parsed?.text || '').trim();
491
+ const truncated = raw.length > maxChars;
492
+ return {
493
+ text: truncated ? raw.slice(0, maxChars) : raw,
494
+ pages: parsed?.numpages || 0,
495
+ truncated,
496
+ };
497
+ }
498
+
499
+ function extractPlainText(filePath, { maxChars }) {
500
+ const buffer = fs.readFileSync(filePath);
501
+ // Best-effort UTF-8; binary files that sneak through the ext list will
502
+ // still decode but likely with replacement chars. That's a signal to the
503
+ // model that this attachment is not meaningful text.
504
+ const raw = buffer.toString('utf-8');
505
+ const truncated = raw.length > maxChars;
506
+ return {
507
+ text: truncated ? raw.slice(0, maxChars) : raw,
508
+ pages: 0,
509
+ truncated,
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Load a single document attachment. Reads locally, extracts text, returns
515
+ * a public metadata block plus the extracted text. Errors bubble as
516
+ * throws — callers should catch per-document and continue.
517
+ */
518
+ export async function loadDocumentAttachment(filePath, {
519
+ cwd = process.cwd(),
520
+ maxBytes = DEFAULT_MAX_DOC_BYTES,
521
+ maxChars = DEFAULT_MAX_DOC_CHARS,
522
+ } = {}) {
523
+ const resolved = path.isAbsolute(filePath) ? filePath : resolveAttachmentPath(filePath, cwd);
524
+ if (!fs.existsSync(resolved)) {
525
+ throw new Error(`Document not found: ${filePath}`);
526
+ }
527
+ const stat = fs.statSync(resolved);
528
+ if (!stat.isFile()) {
529
+ throw new Error(`Not a file: ${filePath}`);
530
+ }
531
+ if (stat.size > maxBytes) {
532
+ throw new Error(
533
+ `Document ${path.basename(resolved)} is ${Math.round(stat.size / 1024)} KB, ` +
534
+ `exceeds ${Math.round(maxBytes / 1024)} KB cap`,
535
+ );
536
+ }
537
+
538
+ const ext = path.extname(resolved).toLowerCase();
539
+ const isPdf = ext === '.pdf';
540
+ const { text, pages, truncated } = isPdf
541
+ ? await extractPdfText(resolved, { maxChars })
542
+ : extractPlainText(resolved, { maxChars });
543
+
544
+ const buffer = fs.readFileSync(resolved);
545
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
546
+
547
+ return {
548
+ id: randomUUID(),
549
+ path: resolved,
550
+ name: path.basename(resolved),
551
+ ext,
552
+ kind: isPdf ? 'pdf' : 'text',
553
+ bytes: stat.size,
554
+ chars: text.length,
555
+ pages,
556
+ truncated,
557
+ sha256,
558
+ text,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Public metadata for a document (drop the text body — that's for the
564
+ * inline block, not the transcript log).
565
+ */
566
+ export function publicDocumentMetadata(doc) {
567
+ if (!doc) return null;
568
+ return {
569
+ id: doc.id,
570
+ name: doc.name,
571
+ path: doc.path,
572
+ ext: doc.ext,
573
+ kind: doc.kind,
574
+ bytes: doc.bytes,
575
+ chars: doc.chars,
576
+ pages: doc.pages,
577
+ truncated: doc.truncated,
578
+ sha256: doc.sha256,
579
+ };
580
+ }
581
+
582
+ export function documentSummaryLine(doc) {
583
+ if (!doc) return '';
584
+ const size = doc.bytes > 1024
585
+ ? `${Math.round(doc.bytes / 1024)} KB`
586
+ : `${doc.bytes} B`;
587
+ const extra = doc.pages ? `, ${doc.pages} page${doc.pages === 1 ? '' : 's'}` : '';
588
+ const trunc = doc.truncated ? ' · truncated' : '';
589
+ return `${doc.name} (${doc.kind}${extra}, ${size}${trunc})`;
590
+ }
591
+
592
+ /**
593
+ * Batch-prepare document attachments (parse + load) with a per-turn
594
+ * character budget. Returns { instruction (@refs stripped), documents,
595
+ * metadata }. Throws if the combined extracted text exceeds
596
+ * DEFAULT_MAX_TURN_DOC_CHARS.
597
+ */
598
+ export async function prepareDocumentAttachments(input, {
599
+ cwd = process.cwd(),
600
+ extraPaths = [],
601
+ maxDocBytes = envInt('BAHULAM_DOC_MAX_BYTES', DEFAULT_MAX_DOC_BYTES),
602
+ maxDocChars = envInt('BAHULAM_DOC_MAX_CHARS', DEFAULT_MAX_DOC_CHARS),
603
+ maxTurnDocChars = envInt('BAHULAM_DOC_MAX_TURN_CHARS', DEFAULT_MAX_TURN_DOC_CHARS),
604
+ } = {}) {
605
+ const parsed = parseAttachmentReferences(input, { cwd });
606
+ const paths = [
607
+ ...parsed.documents.map(ref => ref.path),
608
+ ...extraPaths.map(p => resolveAttachmentPath(p, cwd)),
609
+ ];
610
+ const uniquePaths = [...new Set(paths)];
611
+ const documents = [];
612
+ for (const p of uniquePaths) {
613
+ documents.push(await loadDocumentAttachment(p, { cwd, maxBytes: maxDocBytes, maxChars: maxDocChars }));
614
+ }
615
+ const totalChars = documents.reduce((sum, d) => sum + (d.chars || 0), 0);
616
+ if (totalChars > maxTurnDocChars) {
617
+ throw new Error(
618
+ `Attached documents total ${totalChars.toLocaleString()} chars, ` +
619
+ `exceeds per-turn cap of ${maxTurnDocChars.toLocaleString()}`,
620
+ );
621
+ }
622
+ return {
623
+ instruction: parsed.instruction || String(input || '').trim(),
624
+ documents,
625
+ metadata: documents.map(publicDocumentMetadata),
626
+ };
627
+ }
628
+
629
+ /**
630
+ * Fold extracted document text into the user turn as a bounded block. The
631
+ * agent sees the content directly — no tool call needed. Format is stable
632
+ * so read_attachment output and inlined output look the same shape to the
633
+ * model.
634
+ */
635
+ export function appendDocumentsToInstruction(instruction, documents) {
636
+ const docs = (documents || []).filter(d => d && d.text);
637
+ if (!docs.length) return String(instruction || '');
638
+ const parts = [String(instruction || '').trim()];
639
+ for (const doc of docs) {
640
+ const meta = `${doc.name}` +
641
+ (doc.pages ? ` · ${doc.pages} page${doc.pages === 1 ? '' : 's'}` : '') +
642
+ (doc.truncated ? ` · truncated to ${doc.chars.toLocaleString()} chars` : '');
643
+ parts.push('');
644
+ parts.push(`[Attached document: ${meta}]`);
645
+ parts.push(doc.text);
646
+ }
647
+ return parts.join('\n');
648
+ }
649
+
650
+ export function appendVisionAnalysisToInstruction(instruction, analysis) {
651
+ const summary = String(analysis?.summary || '').trim();
652
+ if (!summary) return String(instruction || '');
653
+ const attachments = Array.isArray(analysis.attachments) ? analysis.attachments : [];
654
+ const lines = attachments.map((att, index) => {
655
+ const dims = att.width && att.height ? `${att.width}x${att.height}` : 'unknown dimensions';
656
+ return `${index + 1}. ${att.name || att.id || 'image'} (${att.mime_type || 'image'}, ${dims}, sha256=${String(att.sha256 || '').slice(0, 12)})`;
657
+ });
658
+ return [
659
+ String(instruction || '').trim(),
660
+ '',
661
+ '[Vision analysis]',
662
+ 'The primary coding agent does not receive raw image pixels. It receives this technical image analysis:',
663
+ lines.length ? `Images:\n${lines.join('\n')}` : '',
664
+ summary,
665
+ ].filter(Boolean).join('\n');
666
+ }