@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,703 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { ContextRetriever } from '../context/retriever.mjs';
7
+ import { buildProjectSkeleton } from '../context/skeleton.mjs';
8
+ import { indexDir as getIndexDir, projectConfigDir, bahulamHome } from '../core/paths.mjs';
9
+
10
+ const RESOURCE_FILE = 'project-resource.json';
11
+
12
+ /**
13
+ * Expand "~" and trim surrounding quotes/whitespace. Does NOT unescape shell
14
+ * meta characters — that is a separate, last-resort step done only if the
15
+ * literal path does not resolve.
16
+ */
17
+ function normalizePathInput(p) {
18
+ let s = String(p || '').trim();
19
+ // Trim balanced surrounding quotes.
20
+ if ((s.startsWith('"') && s.endsWith('"')) ||
21
+ (s.startsWith("'") && s.endsWith("'"))) {
22
+ s = s.slice(1, -1);
23
+ }
24
+ // Tilde expansion (~ or ~/...).
25
+ if (s === '~' || s.startsWith('~/')) {
26
+ s = path.join(os.homedir(), s.slice(1));
27
+ }
28
+ return s;
29
+ }
30
+
31
+ /**
32
+ * Replace common shell escape sequences with their literal characters. Used
33
+ * as a fallback when the literal path does not resolve — the agent may have
34
+ * pasted a copy of what they would type at a shell prompt.
35
+ */
36
+ function unescapeShellPath(p) {
37
+ return String(p || '').replace(/\\([ \t()&$;'"])/g, '$1');
38
+ }
39
+
40
+ const LANGUAGE_EXTENSIONS = new Map([
41
+ ['.py', 'Python'],
42
+ ['.js', 'JavaScript'],
43
+ ['.mjs', 'JavaScript'],
44
+ ['.ts', 'TypeScript'],
45
+ ['.tsx', 'TypeScript'],
46
+ ['.go', 'Go'],
47
+ ['.rs', 'Rust'],
48
+ ['.java', 'Java'],
49
+ ['.rb', 'Ruby'],
50
+ ['.c', 'C'],
51
+ ['.cpp', 'C++'],
52
+ ]);
53
+ const IGNORED_DIRS = new Set([
54
+ '.git', '.bahulam', '.next', '.venv', '__pycache__',
55
+ 'build', 'dist', 'node_modules', 'venv',
56
+ ]);
57
+
58
+ // Files or directories whose presence at the root implies this IS a project.
59
+ // One is enough. Kept broad so we accept Node/Python/Rust/Go/Ruby/Java/C++
60
+ // projects, container-only repos, and Bahulam/agent-configured directories.
61
+ const PROJECT_MARKERS = [
62
+ '.git', '.hg', '.svn',
63
+ '.bahulam', '.kepler', // Bahulam project state
64
+ 'package.json', // Node
65
+ 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile',
66
+ 'Cargo.toml', // Rust
67
+ 'go.mod', // Go
68
+ 'Gemfile', // Ruby
69
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', // Java/Kotlin
70
+ 'Makefile', 'CMakeLists.txt', // C/C++
71
+ 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
72
+ 'AGENTS.md', 'CLAUDE.md', 'KEPLER.md', // Agent config lives at root
73
+ '.editorconfig', // Broad but a strong "this is a repo" signal
74
+ ];
75
+
76
+ // System / user-home roots we refuse outright — indexing these would sweep
77
+ // every project the user has ever touched and produce noise, not signal.
78
+ // Compared per-realpath so symlinks don't sneak past.
79
+ function _dangerousRootSet() {
80
+ const set = new Set([
81
+ '/', '/tmp', '/var', '/etc', '/usr', '/opt',
82
+ '/Applications', '/Library', '/System',
83
+ '/Users', '/home', '/root',
84
+ '/Volumes', '/mnt', '/media',
85
+ ]);
86
+ try { set.add(os.homedir()); } catch {}
87
+ try { set.add(path.parse(os.homedir()).root); } catch {}
88
+ return set;
89
+ }
90
+
91
+ function isDangerousRoot(root) {
92
+ return _dangerousRootSet().has(root);
93
+ }
94
+
95
+ function hasProjectMarkers(root) {
96
+ for (const marker of PROJECT_MARKERS) {
97
+ try {
98
+ if (fs.existsSync(path.join(root, marker))) return true;
99
+ } catch { /* skip unreadable entries */ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ function projectId(canonicalPath) {
105
+ return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
106
+ }
107
+
108
+ function isWithin(root, candidate) {
109
+ const relative = path.relative(root, candidate);
110
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
111
+ }
112
+
113
+ function uniqueValues(values) {
114
+ return [...new Set(values.filter(Boolean))];
115
+ }
116
+
117
+ function canonicalRoot(rootPath) {
118
+ const resolved = path.resolve(normalizePathInput(rootPath));
119
+ try {
120
+ return fs.realpathSync(resolved);
121
+ } catch {
122
+ return resolved;
123
+ }
124
+ }
125
+
126
+ function canonicalizeCandidate(candidate) {
127
+ if (fs.existsSync(candidate)) return fs.realpathSync(candidate);
128
+
129
+ const missing = [];
130
+ let parent = candidate;
131
+ while (!fs.existsSync(parent)) {
132
+ const next = path.dirname(parent);
133
+ if (next === parent) break;
134
+ missing.unshift(path.basename(parent));
135
+ parent = next;
136
+ }
137
+ return path.join(fs.realpathSync(parent), ...missing);
138
+ }
139
+
140
+ function projectFingerprint(projectDir) {
141
+ const hash = crypto.createHash('sha256');
142
+ const queue = [projectDir];
143
+
144
+ while (queue.length > 0) {
145
+ const dir = queue.shift();
146
+ let entries;
147
+ try {
148
+ entries = fs.readdirSync(dir, { withFileTypes: true })
149
+ .sort((a, b) => a.name.localeCompare(b.name));
150
+ } catch {
151
+ continue;
152
+ }
153
+ for (const entry of entries) {
154
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name)) continue;
155
+ const fullPath = path.join(dir, entry.name);
156
+ if (entry.isDirectory()) {
157
+ queue.push(fullPath);
158
+ continue;
159
+ }
160
+ if (!entry.isFile()) continue;
161
+ try {
162
+ const stat = fs.statSync(fullPath);
163
+ hash.update(
164
+ `${path.relative(projectDir, fullPath)}:${stat.size}:${Math.trunc(stat.mtimeMs)}\n`
165
+ );
166
+ } catch { /* file changed during scan */ }
167
+ }
168
+ }
169
+ return hash.digest('hex').slice(0, 16);
170
+ }
171
+
172
+ function detectLanguages(projectDir) {
173
+ const counts = new Map();
174
+ const queue = [projectDir];
175
+ let scanned = 0;
176
+
177
+ while (queue.length > 0 && scanned < 500) {
178
+ const dir = queue.shift();
179
+ let entries;
180
+ try {
181
+ entries = fs.readdirSync(dir, { withFileTypes: true });
182
+ } catch {
183
+ continue;
184
+ }
185
+ for (const entry of entries) {
186
+ if (scanned >= 500) break;
187
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name)) continue;
188
+ const fullPath = path.join(dir, entry.name);
189
+ if (entry.isDirectory()) {
190
+ queue.push(fullPath);
191
+ } else if (entry.isFile()) {
192
+ scanned++;
193
+ const language = LANGUAGE_EXTENSIONS.get(path.extname(entry.name));
194
+ if (language) counts.set(language, (counts.get(language) || 0) + 1);
195
+ }
196
+ }
197
+ }
198
+
199
+ return [...counts.entries()]
200
+ .sort((a, b) => b[1] - a[1])
201
+ .slice(0, 4)
202
+ .map(([language]) => language);
203
+ }
204
+
205
+ function detectCommands(projectDir) {
206
+ const commands = {};
207
+ try {
208
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'));
209
+ if (pkg.scripts?.test) commands.test = 'npm test';
210
+ if (pkg.scripts?.build) commands.build = 'npm run build';
211
+ if (pkg.scripts?.lint) commands.lint = 'npm run lint';
212
+ } catch { /* no package.json */ }
213
+
214
+ if (
215
+ fs.existsSync(path.join(projectDir, 'pyproject.toml')) ||
216
+ fs.existsSync(path.join(projectDir, 'setup.py'))
217
+ ) {
218
+ if (!commands.test) commands.test = 'python -m pytest';
219
+ }
220
+ if (fs.existsSync(path.join(projectDir, 'Makefile')) && !commands.build) {
221
+ commands.build = 'make';
222
+ }
223
+ return commands;
224
+ }
225
+
226
+ function commandVersion(command, args = ['--version']) {
227
+ try {
228
+ const result = spawnSync(command, args, {
229
+ encoding: 'utf-8',
230
+ timeout: 2000,
231
+ windowsHide: true,
232
+ });
233
+ if (result.error || result.status !== 0) return '';
234
+ return `${result.stdout || result.stderr || ''}`.trim().split('\n')[0].slice(0, 120);
235
+ } catch {
236
+ return '';
237
+ }
238
+ }
239
+
240
+ function detectEnvironment() {
241
+ const candidates = [
242
+ ['python', 'python3'],
243
+ ['node', 'node'],
244
+ ['git', 'git'],
245
+ ['npm', 'npm'],
246
+ ['uv', 'uv'],
247
+ ['pytest', 'pytest'],
248
+ ['docker', 'docker'],
249
+ ];
250
+ const tools = {};
251
+ for (const [name, command] of candidates) {
252
+ const version = commandVersion(command);
253
+ if (version) tools[name] = version;
254
+ }
255
+ return {
256
+ platform: os.platform(),
257
+ release: os.release(),
258
+ architecture: os.arch(),
259
+ shell: process.env.SHELL || process.env.ComSpec || '',
260
+ node: process.version,
261
+ tools,
262
+ };
263
+ }
264
+
265
+ function formatResource(resource) {
266
+ const lines = [
267
+ `Project registered: ${resource.name} (project_id=${resource.project_id})`,
268
+ `Root: ${resource.root}`,
269
+ `Languages: ${resource.languages.join(', ') || 'unknown'}`,
270
+ `Index: ${resource.index_status} (${resource.index_version})`,
271
+ ];
272
+ if (resource.environment) {
273
+ const env = resource.environment;
274
+ lines.push(
275
+ `Environment: ${env.platform || 'unknown'} ${env.release || ''} ` +
276
+ `(${env.architecture || 'unknown'}), shell=${env.shell || 'unknown'}, node=${env.node || 'unknown'}`
277
+ );
278
+ const toolVersions = Object.entries(env.tools || {});
279
+ if (toolVersions.length > 0) {
280
+ lines.push(`Available tools: ${toolVersions.map(([name, version]) =>
281
+ `${name}=${version}`).join(', ')}`);
282
+ }
283
+ }
284
+ if (Object.keys(resource.commands).length > 0) {
285
+ lines.push(`Commands: ${Object.entries(resource.commands)
286
+ .map(([name, command]) => `${name}="${command}"`).join(', ')}`);
287
+ }
288
+ if (resource.skills_index && resource.skills_index.length > 0) {
289
+ lines.push(`Skills: ${resource.skills_index.map(s => s.name).join(', ')}`);
290
+ }
291
+ lines.push('', resource.overview);
292
+ if (resource.project_context) {
293
+ lines.push('', '--- Project Context ---', resource.project_context);
294
+ }
295
+ if (resource.style) {
296
+ lines.push('', '--- Project Style ---', resource.style);
297
+ }
298
+ if (resource.goal) {
299
+ lines.push('', '--- Current Goal ---', resource.goal);
300
+ }
301
+ if (resource.plan) {
302
+ lines.push('', '--- Current Plan ---', resource.plan);
303
+ }
304
+ return lines.join('\n');
305
+ }
306
+
307
+ function _readIfExists(dir, filename, maxChars = 8000) {
308
+ try {
309
+ const filePath = path.join(dir, filename);
310
+ if (!fs.existsSync(filePath)) return '';
311
+ const content = fs.readFileSync(filePath, 'utf-8');
312
+ if (content.length > maxChars) {
313
+ // 70/20 head/tail truncation
314
+ const head = Math.floor(maxChars * 0.7);
315
+ const tail = Math.floor(maxChars * 0.2);
316
+ return content.slice(0, head) + '\n\n[...truncated...]\n\n' + content.slice(-tail);
317
+ }
318
+ return content;
319
+ } catch { return ''; }
320
+ }
321
+
322
+ function _scanSkills(keplerDir) {
323
+ const skillsDir = path.join(keplerDir, 'skills');
324
+ if (!fs.existsSync(skillsDir)) return [];
325
+ try {
326
+ return fs.readdirSync(skillsDir, { withFileTypes: true })
327
+ .map(entry => {
328
+ const file = entry.isDirectory()
329
+ ? path.join(skillsDir, entry.name, 'SKILL.md')
330
+ : path.join(skillsDir, entry.name);
331
+ if (!fs.existsSync(file) || !file.endsWith('.md')) return null;
332
+ const content = fs.readFileSync(file, 'utf-8');
333
+ const descMatch = content.match(/^#\s+.*\n+(.+)/);
334
+ return {
335
+ name: entry.isDirectory() ? entry.name : entry.name.replace('.md', ''),
336
+ description: content.match(/^description:\s*(.+)$/mi)?.[1]?.trim()
337
+ || (descMatch ? descMatch[1].slice(0, 100) : entry.name.replace('.md', '')),
338
+ };
339
+ })
340
+ .filter(Boolean);
341
+ } catch { return []; }
342
+ }
343
+
344
+ function defaultScratchRoots() {
345
+ return uniqueValues([
346
+ '/tmp',
347
+ '/private/tmp',
348
+ os.tmpdir(),
349
+ process.env.TMPDIR,
350
+ ...(process.env.KEPLER_SCRATCH_ROOTS || '')
351
+ .split(path.delimiter)
352
+ .map(s => s.trim())
353
+ .filter(Boolean),
354
+ ]).map(canonicalRoot);
355
+ }
356
+
357
+ export class ProjectRegistry {
358
+ constructor() {
359
+ this.projects = new Map();
360
+ this.scratchRoots = new Set(defaultScratchRoots());
361
+ this._globalIdentity = null;
362
+ this._globalPreferences = null;
363
+ this._globalSkills = null;
364
+ }
365
+
366
+ addScratchRoot(rawPath) {
367
+ if (!rawPath) return null;
368
+ const root = canonicalRoot(rawPath);
369
+ this.scratchRoots.add(root);
370
+ return root;
371
+ }
372
+
373
+ /**
374
+ * Load global context from ~/.bahulam/ (once per session).
375
+ */
376
+ loadGlobalContext() {
377
+ if (this._globalIdentity !== null) return;
378
+ // Resolver — prefers ~/.bahulam, falls back to ~/.kepler for legacy installs.
379
+ const globalDir = bahulamHome();
380
+ this._globalIdentity = _readIfExists(globalDir, 'identity.md', 4000);
381
+ this._globalPreferences = _readIfExists(globalDir, 'preferences.md', 2000);
382
+ this._globalSkills = _scanSkills(globalDir);
383
+ }
384
+
385
+ /**
386
+ * Get the global agent context (identity, preferences, skills).
387
+ */
388
+ getGlobalContext() {
389
+ this.loadGlobalContext();
390
+ return {
391
+ identity: this._globalIdentity || '',
392
+ preferences: this._globalPreferences || '',
393
+ skills: this._globalSkills || [],
394
+ };
395
+ }
396
+
397
+ // PRD-69 project context is live metadata, not index cache. Re-read it on
398
+ // every registration attempt so repeated get_project_overview calls pick up
399
+ // .bahulam/KEPLER.md, goal/plan/style, skills, AGENTS.md, etc. changes.
400
+ _attachLiveContext(resource, root) {
401
+ // Resolver — prefers .bahulam/, falls back to .kepler/ for legacy projects.
402
+ const keplerDir = projectConfigDir(root);
403
+ resource.environment = detectEnvironment();
404
+ resource.project_context = _readIfExists(keplerDir, 'KEPLER.md', 10000) ||
405
+ _readIfExists(root, 'KEPLER.md', 10000) ||
406
+ _readIfExists(keplerDir, 'project.md', 8000);
407
+ resource.style = _readIfExists(keplerDir, 'style.md', 4000);
408
+ resource.goal = _readIfExists(keplerDir, 'goal.md', 2000);
409
+ resource.plan = _readIfExists(keplerDir, 'plan.md', 6000);
410
+ resource.skills_index = _scanSkills(keplerDir);
411
+
412
+ if (!resource.project_context) {
413
+ for (const name of ['.bahulam.md', 'AGENTS.md', 'CLAUDE.md']) {
414
+ const content = _readIfExists(root, name, 8000);
415
+ if (content) { resource.project_context = content; break; }
416
+ }
417
+ }
418
+ return resource;
419
+ }
420
+
421
+ async register(rawPath, { forceRefresh = false, force_refresh = false, bypassProjectMarkers = false } = {}) {
422
+ if (!rawPath) {
423
+ throw new Error('get_project_overview requires a project path');
424
+ }
425
+
426
+ // LLM sometimes passes shell-escaped paths ("Tarang\ Orca") or paths
427
+ // beginning with "~". Normalize defensively so the tool does not bounce
428
+ // back a "not found" error on a path that's correct apart from quoting.
429
+ rawPath = normalizePathInput(rawPath);
430
+
431
+ if (!path.isAbsolute(rawPath)) {
432
+ rawPath = path.resolve(process.cwd(), rawPath);
433
+ }
434
+
435
+ let root;
436
+ try {
437
+ root = fs.realpathSync(rawPath);
438
+ } catch {
439
+ // Try the unescaped variant explicitly so the error message can
440
+ // tell the agent what it actually attempted.
441
+ const unescaped = unescapeShellPath(rawPath);
442
+ if (unescaped !== rawPath) {
443
+ try { root = fs.realpathSync(unescaped); }
444
+ catch { throw new Error(`Project path not found: ${rawPath} (also tried ${unescaped})`); }
445
+ } else {
446
+ throw new Error(`Project path not found: ${rawPath}`);
447
+ }
448
+ }
449
+ if (!fs.statSync(root).isDirectory()) {
450
+ throw new Error(`Project path is not a directory: ${root}`);
451
+ }
452
+ if (isDangerousRoot(root)) {
453
+ throw new Error(
454
+ `Refusing to index ${root} — too broad or system-level. ` +
455
+ `Pass a specific project directory, or answer the user's question ` +
456
+ `without get_project_overview if it doesn't need project files.`
457
+ );
458
+ }
459
+ // Programmatic file-read registration (registerFileRead) bypasses the
460
+ // marker check — the user has a specific file in hand and we index its
461
+ // parent so tool guards don't block the read. The user-facing
462
+ // get_project_overview tool DOES enforce the check.
463
+ if (!bypassProjectMarkers && !hasProjectMarkers(root)) {
464
+ throw new Error(
465
+ `No project markers found at ${root} (checked for .git, package.json, ` +
466
+ `pyproject.toml, Cargo.toml, go.mod, Gemfile, pom.xml, Makefile, ` +
467
+ `Dockerfile, AGENTS.md, .bahulam/). If the user's request does not ` +
468
+ `require this codebase, do NOT call get_project_overview again for ` +
469
+ `this session — answer the question directly. If it does require code, ` +
470
+ `ask the user to point at the correct project directory.`
471
+ );
472
+ }
473
+
474
+ const id = projectId(root);
475
+ const fingerprint = projectFingerprint(root);
476
+ const existing = this.projects.get(id);
477
+ const shouldForceRefresh = Boolean(forceRefresh || force_refresh);
478
+ if (existing && !shouldForceRefresh && existing.resource.index_version === fingerprint) {
479
+ this._attachLiveContext(existing.resource, root);
480
+ return {
481
+ already_registered: true,
482
+ refreshed: false,
483
+ resource: existing.resource,
484
+ output:
485
+ `Project already registered as project_id=${id}. ` +
486
+ `Use project_id=${id} with search_code and use absolute paths for file tools.`,
487
+ };
488
+ }
489
+
490
+ const retriever = new ContextRetriever(root);
491
+ const resourcePath = path.join(getIndexDir(root), RESOURCE_FILE);
492
+ let resource = null;
493
+
494
+ try {
495
+ const persisted = JSON.parse(fs.readFileSync(resourcePath, 'utf-8'));
496
+ if (!shouldForceRefresh && persisted.index_version === fingerprint && retriever.loadIndex()) {
497
+ resource = persisted;
498
+ }
499
+ } catch { /* missing or stale index */ }
500
+
501
+ if (!resource) {
502
+ await retriever.buildIndex();
503
+ resource = {
504
+ project_id: id,
505
+ root,
506
+ name: path.basename(root),
507
+ languages: detectLanguages(root),
508
+ commands: detectCommands(root),
509
+ overview: buildProjectSkeleton(root, { maxFiles: 150, maxChars: 2500 }) ||
510
+ `Project at ${root}`,
511
+ index_status: 'ready',
512
+ index_version: fingerprint,
513
+ };
514
+ fs.writeFileSync(resourcePath, JSON.stringify(resource));
515
+ }
516
+
517
+ this._attachLiveContext(resource, root);
518
+ this.projects.set(id, { resource, retriever });
519
+ const refreshed = Boolean(existing);
520
+ return {
521
+ already_registered: refreshed,
522
+ refreshed,
523
+ resource,
524
+ output: refreshed
525
+ ? `Project refreshed: ${resource.name} (project_id=${id})\nRoot: ${resource.root}`
526
+ : formatResource(resource),
527
+ };
528
+ }
529
+
530
+ resources() {
531
+ return [...this.projects.values()].map(({ resource }) => resource);
532
+ }
533
+
534
+ get(projectIdValue) {
535
+ return this.projects.get(projectIdValue) || null;
536
+ }
537
+
538
+ projectScratchRoots() {
539
+ return this.resources().map(resource => path.join(projectConfigDir(resource.root), 'tmp'));
540
+ }
541
+
542
+ allowedScratchRoots() {
543
+ return uniqueValues([
544
+ ...this.scratchRoots,
545
+ ...this.projectScratchRoots(),
546
+ ]).map(canonicalRoot);
547
+ }
548
+
549
+ isAllowedScratchPath(filePath) {
550
+ const normalized = normalizePathInput(filePath);
551
+ const candidate = canonicalizeCandidate(path.resolve(normalized));
552
+ return this.allowedScratchRoots().some(root => isWithin(root, candidate));
553
+ }
554
+
555
+ async registerFileRead(candidate) {
556
+ if (!candidate || !fs.existsSync(candidate)) return null;
557
+ let stat;
558
+ try {
559
+ stat = fs.statSync(candidate);
560
+ } catch {
561
+ return null;
562
+ }
563
+ if (!stat.isFile()) return null;
564
+
565
+ const filePath = fs.realpathSync(candidate);
566
+ const dir = path.dirname(filePath);
567
+ if (dir === path.parse(dir).root || dir === os.homedir()) return null;
568
+
569
+ const registered = await this.register(dir, { bypassProjectMarkers: true });
570
+ const owner = this.projects.get(registered.resource.project_id);
571
+ if (!owner) return null;
572
+
573
+ const files = Array.isArray(owner.resource.files_read)
574
+ ? owner.resource.files_read
575
+ : [];
576
+ if (!files.includes(filePath)) {
577
+ owner.resource.files_read = [...files, filePath];
578
+ }
579
+ return { filePath, project: owner, registered };
580
+ }
581
+
582
+ async resolvePath(rawPath, projectIdValue, { allowMissing = false, allowExternalFileRead = false } = {}) {
583
+ let root = null;
584
+ if (projectIdValue) {
585
+ root = this.get(projectIdValue)?.resource.root || null;
586
+ if (!root) throw new Error(`Unknown project_id: ${projectIdValue}`);
587
+ }
588
+
589
+ if (!rawPath) {
590
+ if (root) return root;
591
+ if (this.projects.size === 1) return this.resources()[0].root;
592
+ // Fall back to the first registered project when the model omits
593
+ // both path and project_id. Beats throwing on an inferable case.
594
+ const first = this.resources()[0];
595
+ if (first) return first.root;
596
+ throw new Error('No projects registered. Call get_project_overview first.');
597
+ }
598
+
599
+ // LLM frequently passes shell-quoted paths copied from a terminal,
600
+ // e.g. "Tarang\ Orca/src/app/\(kepler\)/page.tsx". Normalize here so
601
+ // every tool benefits, not just get_project_overview.
602
+ rawPath = normalizePathInput(rawPath);
603
+
604
+ const buildCandidate = (input) => {
605
+ if (path.isAbsolute(input)) {
606
+ return canonicalizeCandidate(path.resolve(input));
607
+ }
608
+ if (!root) {
609
+ if (this.projects.size === 1) {
610
+ return canonicalizeCandidate(path.resolve(this.resources()[0].root, input));
611
+ }
612
+ if (this.projects.size > 1) {
613
+ throw new Error('Relative path requires project_id when multiple projects are registered. Pass project_id or use an absolute path.');
614
+ }
615
+ throw new Error('No projects registered. Call get_project_overview first.');
616
+ }
617
+ return canonicalizeCandidate(path.resolve(root, input));
618
+ };
619
+
620
+ let candidate = buildCandidate(rawPath);
621
+
622
+ const findContaining = (cand) => [...this.projects.values()].find(({ resource }) =>
623
+ isWithin(resource.root, cand)
624
+ );
625
+ const findScratchRoot = (cand) => this.allowedScratchRoots().find(scratchRoot =>
626
+ isWithin(scratchRoot, cand)
627
+ );
628
+
629
+ let containingProject = findContaining(candidate);
630
+ let containingScratchRoot = containingProject ? null : findScratchRoot(candidate);
631
+
632
+ // Two reasons to try the unescaped variant:
633
+ // (1) candidate is outside every project root (literal "Tarang\ Orca"
634
+ // does not contain a real project), or
635
+ // (2) candidate is inside a root but does not exist on disk because
636
+ // a path segment like "\(kepler\)" only resolves once unescaped.
637
+ // We retry once on the unescaped form before raising.
638
+ const needsRetry = !containingProject ||
639
+ (!allowMissing && !fs.existsSync(candidate));
640
+ if (needsRetry) {
641
+ const unescaped = unescapeShellPath(rawPath);
642
+ if (unescaped !== rawPath) {
643
+ try {
644
+ const altCandidate = buildCandidate(unescaped);
645
+ const altProject = findContaining(altCandidate);
646
+ if (altProject && (allowMissing || fs.existsSync(altCandidate))) {
647
+ candidate = altCandidate;
648
+ containingProject = altProject;
649
+ containingScratchRoot = null;
650
+ } else {
651
+ const altScratchRoot = findScratchRoot(altCandidate);
652
+ if (altScratchRoot && (allowMissing || fs.existsSync(altCandidate))) {
653
+ candidate = altCandidate;
654
+ containingScratchRoot = altScratchRoot;
655
+ }
656
+ }
657
+ } catch { /* fall through to the original error */ }
658
+ }
659
+ }
660
+
661
+ if (!containingProject && !containingScratchRoot) {
662
+ if (allowExternalFileRead) {
663
+ let external = await this.registerFileRead(candidate);
664
+ if (!external) {
665
+ const unescaped = unescapeShellPath(rawPath);
666
+ if (unescaped !== rawPath) {
667
+ try {
668
+ external = await this.registerFileRead(buildCandidate(unescaped));
669
+ } catch { /* keep original outside-root error */ }
670
+ }
671
+ }
672
+ if (external) return external.filePath;
673
+ }
674
+ throw new Error(`Path is outside registered project roots: ${rawPath}`);
675
+ }
676
+ if (!allowMissing && !fs.existsSync(candidate)) {
677
+ throw new Error(`Path not found: ${rawPath}`);
678
+ }
679
+ return candidate;
680
+ }
681
+
682
+ projectForPath(filePath) {
683
+ const normalized = normalizePathInput(filePath);
684
+ const candidate = canonicalizeCandidate(path.resolve(normalized));
685
+ const direct = [...this.projects.values()].find(({ resource }) =>
686
+ isWithin(resource.root, candidate)
687
+ );
688
+ if (direct) return direct;
689
+ // Same unescape fallback used in resolvePath.
690
+ const unescaped = unescapeShellPath(normalized);
691
+ if (unescaped !== normalized) {
692
+ const altCandidate = canonicalizeCandidate(path.resolve(unescaped));
693
+ return [...this.projects.values()].find(({ resource }) =>
694
+ isWithin(resource.root, altCandidate)
695
+ ) || null;
696
+ }
697
+ return null;
698
+ }
699
+
700
+ reset() {
701
+ this.projects.clear();
702
+ }
703
+ }