@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,255 @@
1
+ /**
2
+ * Prose document chunker — JS port of the backend's Python chunker.
3
+ *
4
+ * Mirrors `codekepler-backend/app/agent/tools/shared/documents.py` so a
5
+ * file chunked here (CLI local path) and a file chunked server-side
6
+ * (chat upload or server `path` mode) produce byte-comparable chunks
7
+ * with the same page/chunk numbering. Callers can slice/reference
8
+ * chunks by (page_no, chunk_no) across surfaces.
9
+ *
10
+ * Constants come from Django's Phase 1 inline chunker
11
+ * (retail/chat_uploads/services.py) — the single source of truth for
12
+ * the whole platform.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────
15
+ * Retriever interface — this module produces chunks; a retriever ranks
16
+ * them. Today the CLI has one retriever (BM25 via
17
+ * src/context/retriever.mjs). Desktop apps and richer offline setups
18
+ * will add an embedding-backed retriever. Both consume the same chunk
19
+ * shape produced here so the swap is transparent to callers:
20
+ *
21
+ * Chunk (produced by this module):
22
+ * { page: number|null, chunk_no: number, text: string, tokens: number }
23
+ *
24
+ * Retriever (any implementation):
25
+ * addSource(sourceId, chunks): void
26
+ * search(query, { topK, sources? }): Array<{
27
+ * sourceId, page, chunk_no, text, score
28
+ * }>
29
+ * removeSource(sourceId): void // invalidate on file change
30
+ *
31
+ * The `read_attachment` tool talks to the retriever interface, never
32
+ * to a specific implementation. Adding embeddings later means writing
33
+ * an `EmbeddingRetriever` that satisfies this contract — no changes to
34
+ * the chunker or the tool.
35
+ * ─────────────────────────────────────────────────────────────────────
36
+ *
37
+ * Divergences from Python (documented):
38
+ * - JS String indexing is UTF-16 code units, Python str is code points.
39
+ * For BMP-only text (nearly all documents) the boundaries match
40
+ * exactly. Non-BMP characters (emoji, some CJK) may fall a code unit
41
+ * off vs Python; not a correctness issue for retrieval.
42
+ * - TEXT_LIKE_MIMES is a JS-only superset of Python's TEXT_MIMES —
43
+ * the CLI has historically supported json/yaml/html/log/rst, and
44
+ * the chunker doesn't care about the surface syntax. Retrieval
45
+ * quality on structured files (json/yaml) will be worse than on
46
+ * prose; document that in the tool description, not here.
47
+ */
48
+
49
+ import * as fs from 'node:fs';
50
+ import * as path from 'node:path';
51
+
52
+ // Byte-exact match with documents.py:29-31.
53
+ export const CHUNK_TOKENS = 800;
54
+ export const CHUNK_OVERLAP = 100;
55
+ export const CHARS_PER_TOKEN = 4;
56
+
57
+ // Strict server-parity set — same 5 mimes as documents.py.
58
+ export const TEXT_MIMES = new Set([
59
+ 'text/plain',
60
+ 'text/markdown',
61
+ 'text/csv',
62
+ 'text/tab-separated-values',
63
+ ]);
64
+ export const PDF_MIMES = new Set(['application/pdf']);
65
+ export const DOCUMENT_MIMES = new Set([...TEXT_MIMES, ...PDF_MIMES]);
66
+
67
+ // Wider set the CLI already supports at the `read_attachment` tool layer.
68
+ // Chunking works fine on any UTF-8 text — retrieval quality on structured
69
+ // formats is up to the retriever + query.
70
+ export const TEXT_LIKE_MIMES = new Set([
71
+ ...TEXT_MIMES,
72
+ 'application/json',
73
+ 'application/x-yaml',
74
+ 'application/toml',
75
+ 'text/yaml',
76
+ 'text/html',
77
+ 'text/x-log',
78
+ 'text/x-rst',
79
+ 'text/x-restructuredtext',
80
+ ]);
81
+
82
+ // Extension → mime map. Kept small and explicit — Python's
83
+ // mimetypes.guess_type varies by OS registry; this table is stable.
84
+ const EXT_TO_MIME = new Map([
85
+ ['.txt', 'text/plain'],
86
+ ['.log', 'text/x-log'],
87
+ ['.md', 'text/markdown'],
88
+ ['.markdown', 'text/markdown'],
89
+ ['.mdx', 'text/markdown'], // MDX = markdown + JSX; treat as prose for retrieval
90
+ ['.csv', 'text/csv'],
91
+ ['.tsv', 'text/tab-separated-values'],
92
+ ['.json', 'application/json'],
93
+ ['.yaml', 'application/x-yaml'],
94
+ ['.yml', 'application/x-yaml'],
95
+ ['.toml', 'application/toml'],
96
+ ['.html', 'text/html'],
97
+ ['.htm', 'text/html'],
98
+ ['.rst', 'text/x-rst'],
99
+ ['.pdf', 'application/pdf'],
100
+ ]);
101
+
102
+ /**
103
+ * Best-effort mime for a local file (extension-only, no magic-byte sniff).
104
+ * Files with no matching extension return 'application/octet-stream',
105
+ * consistent with Python's mimetypes.
106
+ */
107
+ export function guessMime(filePath) {
108
+ const ext = path.extname(String(filePath || '')).toLowerCase();
109
+ return EXT_TO_MIME.get(ext) || 'application/octet-stream';
110
+ }
111
+
112
+ /**
113
+ * Chunk a text string with the 800/100 sliding window (character-based).
114
+ * Byte-comparable to Python's chunk_text() — see documents.py:40.
115
+ *
116
+ * @param {string} text
117
+ * @returns {Array<{chunk_no: number, text: string, tokens: number}>}
118
+ */
119
+ export function chunkText(text) {
120
+ if (!text) return [];
121
+ const stepChars = (CHUNK_TOKENS - CHUNK_OVERLAP) * CHARS_PER_TOKEN;
122
+ const window = CHUNK_TOKENS * CHARS_PER_TOKEN;
123
+ const out = [];
124
+ let i = 0;
125
+ let chunkNo = 0;
126
+ const len = text.length;
127
+ while (i < len) {
128
+ const segment = text.substring(i, i + window);
129
+ const tokens = Math.max(1, Math.floor(segment.length / CHARS_PER_TOKEN));
130
+ out.push({ chunk_no: chunkNo, text: segment, tokens });
131
+ chunkNo += 1;
132
+ i += stepChars;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Extract per-page text from PDF bytes. Returns [{page, text}] with
139
+ * 1-indexed page numbers. Scanned/OCR-only pages come back with empty
140
+ * text — callers skip those (matches Python behavior).
141
+ *
142
+ * Uses the `pdf-parse` npm dep already in the CLI. Imported from
143
+ * `lib/pdf-parse.js` (not the default entry) to skip the debug-hook
144
+ * that opens a bundled test PDF at load time and fails in production.
145
+ */
146
+ export async function extractPdfPages(buffer) {
147
+ const { default: pdfParse } = await import('pdf-parse/lib/pdf-parse.js');
148
+ const pageTexts = [];
149
+ try {
150
+ await pdfParse(buffer, {
151
+ // pdf-parse calls this per page in page order (pageIndex is 0-based).
152
+ // We accumulate into an array indexed by pageIndex to be defensive
153
+ // against any out-of-order rendering.
154
+ pagerender: async (pageData) => {
155
+ try {
156
+ const content = await pageData.getTextContent();
157
+ const text = (content.items || [])
158
+ .map(item => (typeof item.str === 'string' ? item.str : ''))
159
+ .join(' ');
160
+ const idx = typeof pageData.pageIndex === 'number' ? pageData.pageIndex : pageTexts.length;
161
+ pageTexts[idx] = text;
162
+ return text;
163
+ } catch {
164
+ return '';
165
+ }
166
+ },
167
+ });
168
+ } catch {
169
+ return [];
170
+ }
171
+ return pageTexts.map((text, i) => ({ page: i + 1, text: text || '' }));
172
+ }
173
+
174
+ /**
175
+ * Extract chunks from raw bytes. Returns the same shape as Python's
176
+ * extract_from_bytes(): [{page, chunk_no, text, tokens}].
177
+ *
178
+ * page is null for text mimes, 1-indexed for PDF pages.
179
+ * chunk_no is 0-indexed and monotonic across the whole document
180
+ * (matches Python `global_chunk` behavior at documents.py:109-115).
181
+ * Unsupported mimes return an empty array.
182
+ *
183
+ * @param {Buffer} buffer
184
+ * @param {string} mime
185
+ * @param {{textMimes?: Set<string>}} [opts] override which mimes are
186
+ * treated as chunkable text. Defaults to TEXT_LIKE_MIMES (CLI's
187
+ * permissive set). Pass TEXT_MIMES for strict server parity.
188
+ */
189
+ export async function extractFromBytes(buffer, mime, opts = {}) {
190
+ const normalizedMime = String(mime || '').toLowerCase();
191
+ const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
192
+
193
+ if (textMimes.has(normalizedMime)) {
194
+ const text = buffer.toString('utf8');
195
+ return chunkText(text).map(c => ({
196
+ page: null,
197
+ chunk_no: c.chunk_no,
198
+ text: c.text,
199
+ tokens: c.tokens,
200
+ }));
201
+ }
202
+
203
+ if (PDF_MIMES.has(normalizedMime)) {
204
+ const pages = await extractPdfPages(buffer);
205
+ const out = [];
206
+ let globalChunk = 0;
207
+ for (const { page, text } of pages) {
208
+ if (!text || !text.trim()) continue;
209
+ for (const c of chunkText(text)) {
210
+ out.push({ page, chunk_no: globalChunk, text: c.text, tokens: c.tokens });
211
+ globalChunk += 1;
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ return [];
218
+ }
219
+
220
+ /**
221
+ * Read a local file and chunk it. Returns { mime, chunks }; both empty
222
+ * on unresolvable path or unsupported mime — same contract as Python's
223
+ * extract_from_path(). Path resolution is the caller's responsibility
224
+ * (the CLI uses projectRegistry.resolvePath to enforce workspace bounds
225
+ * before calling here).
226
+ *
227
+ * @param {string} absPath absolute, already-resolved file path
228
+ * @param {{textMimes?: Set<string>}} [opts]
229
+ * @returns {Promise<{mime: string, chunks: Array<object>}>}
230
+ */
231
+ export async function extractFromPath(absPath, opts = {}) {
232
+ let stat;
233
+ try {
234
+ stat = fs.statSync(absPath);
235
+ } catch {
236
+ return { mime: '', chunks: [] };
237
+ }
238
+ if (!stat.isFile()) return { mime: '', chunks: [] };
239
+
240
+ const mime = guessMime(absPath);
241
+ const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
242
+ if (!textMimes.has(mime) && !PDF_MIMES.has(mime)) {
243
+ return { mime, chunks: [] };
244
+ }
245
+
246
+ let buffer;
247
+ try {
248
+ buffer = fs.readFileSync(absPath);
249
+ } catch {
250
+ return { mime, chunks: [] };
251
+ }
252
+
253
+ const chunks = await extractFromBytes(buffer, mime, opts);
254
+ return { mime, chunks };
255
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Context Retriever — T20: Unified context retrieval with BM25.
3
+ * Indexes project files and retrieves relevant chunks for LLM context.
4
+ */
5
+
6
+ import { BM25Index } from './bm25.mjs';
7
+ import { SymbolIndexer } from './symbol-indexer.mjs';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { indexDir as getIndexDir } from '../core/paths.mjs';
11
+
12
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', '.bahulam', '.kepler', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next']);
13
+ const CODE_EXTS = new Set(['.js', '.mjs', '.ts', '.tsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.cpp', '.h', '.css', '.html', '.json', '.yaml', '.yml', '.toml', '.md', '.sh']);
14
+ const SYMBOL_EXTS = new Set(['.py', '.js', '.mjs', '.ts', '.tsx', '.jsx', '.go', '.rs']);
15
+ const MAX_FILE_SIZE = 100_000; // 100KB
16
+ const CHUNK_LINES = 50;
17
+ const CHUNK_OVERLAP = 10;
18
+
19
+ export class ContextRetriever {
20
+ constructor(projectDir = process.cwd()) {
21
+ this.projectDir = projectDir;
22
+ this.indexDir = getIndexDir(projectDir);
23
+ this.index = null;
24
+ this.symbolIndexer = null;
25
+ this.chunkTexts = new Map(); // id → original text content
26
+ }
27
+
28
+ /** Build or rebuild the search index (BM25 chunks + symbol index). */
29
+ async buildIndex() {
30
+ const files = this._scanFiles(this.projectDir);
31
+ const documents = [];
32
+
33
+ // Symbol indexer for AST-based search
34
+ this.symbolIndexer = new SymbolIndexer();
35
+ await this.symbolIndexer.init();
36
+
37
+ for (const filePath of files) {
38
+ try {
39
+ const content = fs.readFileSync(filePath, 'utf-8');
40
+ const relPath = path.relative(this.projectDir, filePath);
41
+
42
+ // BM25 chunks (existing behavior)
43
+ const chunks = this._chunkFile(content, relPath);
44
+ documents.push(...chunks);
45
+
46
+ // Symbol extraction for code files
47
+ const ext = path.extname(filePath).toLowerCase();
48
+ if (SYMBOL_EXTS.has(ext)) {
49
+ await this.symbolIndexer.indexFile(relPath, content);
50
+ }
51
+ } catch { /* skip unreadable files */ }
52
+ }
53
+
54
+ this.index = new BM25Index();
55
+ this.index.buildIndex(documents);
56
+
57
+ // Store chunk texts for retrieval
58
+ this.chunkTexts = new Map();
59
+ for (const doc of documents) {
60
+ this.chunkTexts.set(doc.id, doc.text);
61
+ }
62
+
63
+ // Persist
64
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
65
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
66
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
67
+ fs.writeFileSync(path.join(this.indexDir, 'symbols.json'), JSON.stringify(this.symbolIndexer.toJSON()));
68
+
69
+ return { fileCount: files.length, chunkCount: documents.length, symbolCount: this.symbolIndexer.symbolCount };
70
+ }
71
+
72
+ /**
73
+ * Incrementally update index for a single changed file.
74
+ * Re-chunks the file and replaces its entries in the BM25 index.
75
+ * ~5-50ms per file — safe to call after every edit/write.
76
+ */
77
+ updateFile(filePath) {
78
+ if (!this.index) {
79
+ if (!this.loadIndex()) return false;
80
+ }
81
+
82
+ const absPath = path.resolve(filePath);
83
+ const relPath = path.relative(this.projectDir, absPath);
84
+
85
+ // Remove old chunks for this file
86
+ const oldIds = new Set();
87
+ for (const doc of this.index.docs) {
88
+ if (doc.id === relPath || doc.id.startsWith(relPath + ':')) {
89
+ oldIds.add(doc.id);
90
+ }
91
+ }
92
+
93
+ // Collect remaining documents (excluding old chunks for this file)
94
+ const remainingDocs = [];
95
+ for (const doc of this.index.docs) {
96
+ if (!oldIds.has(doc.id)) {
97
+ // Reconstruct text from tf map for rebuild
98
+ remainingDocs.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
99
+ }
100
+ }
101
+
102
+ // Remove old chunk texts
103
+ for (const id of oldIds) this.chunkTexts.delete(id);
104
+
105
+ // Re-chunk if file still exists (delete = just remove)
106
+ const newChunks = [];
107
+ if (fs.existsSync(absPath)) {
108
+ try {
109
+ const content = fs.readFileSync(absPath, 'utf-8');
110
+ newChunks.push(...this._chunkFile(content, relPath));
111
+ } catch { /* skip unreadable */ }
112
+ }
113
+
114
+ // Rebuild index from remaining + new chunks
115
+ // This is fast (~5-20ms) because BM25 build is O(n) on token count
116
+ const allDocs = [...remainingDocs, ...newChunks];
117
+ this.index = new BM25Index();
118
+ this.index.buildIndex(allDocs);
119
+
120
+ // Update chunk texts
121
+ for (const chunk of newChunks) {
122
+ this.chunkTexts.set(chunk.id, chunk.text);
123
+ }
124
+
125
+ // Persist updated index
126
+ try {
127
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
128
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
129
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
130
+ } catch { /* best-effort persist */ }
131
+
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Add already-chunked prose content (from prose-chunker.mjs) to the
137
+ * shared BM25 index — no re-read or re-chunking. Used by
138
+ * `read_attachment` to make docs discoverable by `search_code` /
139
+ * future `search_document` without a separate index.
140
+ *
141
+ * Chunk IDs are shaped `${sourceId}#c${chunk_no}` — the `#c`
142
+ * separator makes them distinguishable from code IDs (which use `:`)
143
+ * so `updateFile` for code files won't accidentally drop prose
144
+ * chunks and vice versa.
145
+ *
146
+ * Re-adding the same sourceId replaces its chunks (idempotent —
147
+ * safe to call on every `read_attachment`).
148
+ *
149
+ * @param {string} sourceId stable id, usually a project-relative path
150
+ * @param {Array<{page: (number|null), chunk_no: number, text: string, tokens?: number}>} chunks
151
+ * @returns {number} chunks indexed
152
+ */
153
+ addProseChunks(sourceId, chunks) {
154
+ if (!Array.isArray(chunks) || chunks.length === 0) return 0;
155
+ if (!sourceId || typeof sourceId !== 'string') return 0;
156
+
157
+ if (!this.index) {
158
+ if (!this.loadIndex()) {
159
+ this.index = new BM25Index();
160
+ this.chunkTexts = new Map();
161
+ }
162
+ }
163
+
164
+ // Drop prior chunks for this source (idempotent).
165
+ const sourcePrefix = `${sourceId}#c`;
166
+ const oldIds = new Set();
167
+ for (const doc of this.index.docs) {
168
+ if (doc.id.startsWith(sourcePrefix)) oldIds.add(doc.id);
169
+ }
170
+ for (const id of oldIds) this.chunkTexts.delete(id);
171
+
172
+ // Collect surviving docs — reconstruct text from stored chunkTexts
173
+ // (BM25Index only stores tf maps + lengths, not raw text).
174
+ const remaining = [];
175
+ for (const doc of this.index.docs) {
176
+ if (!oldIds.has(doc.id)) {
177
+ remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
178
+ }
179
+ }
180
+
181
+ // Prep new prose docs — prefix indexed text with sourceId so BM25
182
+ // gets signal from the filename (mirrors how _chunkFile embeds
183
+ // relPath). Page tag included so page-scoped queries can hit it.
184
+ const newDocs = chunks.map(c => {
185
+ const id = `${sourceId}#c${c.chunk_no}`;
186
+ const pageTag = c.page != null ? ` page:${c.page}` : '';
187
+ const indexedText = `${sourceId}${pageTag}\n${c.text}`;
188
+ return { id, text: indexedText };
189
+ });
190
+
191
+ // Rebuild — BM25 needs IDF recomputed across all docs.
192
+ this.index = new BM25Index();
193
+ this.index.buildIndex([...remaining, ...newDocs]);
194
+ for (const doc of newDocs) {
195
+ this.chunkTexts.set(doc.id, doc.text);
196
+ }
197
+
198
+ // Persist. Best-effort — an unwritable indexDir shouldn't fail
199
+ // the tool call.
200
+ try {
201
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
202
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
203
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
204
+ } catch { /* best-effort persist */ }
205
+
206
+ return newDocs.length;
207
+ }
208
+
209
+ /**
210
+ * Remove all chunks belonging to a source (either code path or
211
+ * prose sourceId). Called when a file is deleted so stale hits
212
+ * don't linger in the index.
213
+ */
214
+ removeSource(sourceId) {
215
+ if (!this.index) {
216
+ if (!this.loadIndex()) return 0;
217
+ }
218
+ const oldIds = new Set();
219
+ for (const doc of this.index.docs) {
220
+ // Code IDs: exact match or `sourceId:` prefix (line/AST chunks).
221
+ // Prose IDs: `sourceId#c` prefix.
222
+ if (
223
+ doc.id === sourceId
224
+ || doc.id.startsWith(`${sourceId}:`)
225
+ || doc.id.startsWith(`${sourceId}#c`)
226
+ ) {
227
+ oldIds.add(doc.id);
228
+ }
229
+ }
230
+ if (oldIds.size === 0) return 0;
231
+ for (const id of oldIds) this.chunkTexts.delete(id);
232
+
233
+ const remaining = [];
234
+ for (const doc of this.index.docs) {
235
+ if (!oldIds.has(doc.id)) {
236
+ remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
237
+ }
238
+ }
239
+
240
+ this.index = new BM25Index();
241
+ this.index.buildIndex(remaining);
242
+
243
+ try {
244
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
245
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
246
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
247
+ } catch { /* best-effort */ }
248
+
249
+ return oldIds.size;
250
+ }
251
+
252
+ /** Load persisted index. */
253
+ loadIndex() {
254
+ const indexPath = path.join(this.indexDir, 'bm25.json');
255
+ const chunksPath = path.join(this.indexDir, 'chunks.json');
256
+ const symbolsPath = path.join(this.indexDir, 'symbols.json');
257
+ if (!fs.existsSync(indexPath)) return false;
258
+ try {
259
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
260
+ this.index = BM25Index.fromJSON(data);
261
+
262
+ if (fs.existsSync(chunksPath)) {
263
+ const chunks = JSON.parse(fs.readFileSync(chunksPath, 'utf-8'));
264
+ this.chunkTexts = new Map(Object.entries(chunks));
265
+ }
266
+
267
+ if (fs.existsSync(symbolsPath)) {
268
+ const symData = JSON.parse(fs.readFileSync(symbolsPath, 'utf-8'));
269
+ this.symbolIndexer = SymbolIndexer.fromJSON(symData);
270
+ }
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Search symbols (functions, classes, methods) by query.
279
+ * Returns structured results with file:line, signature, parent class.
280
+ */
281
+ searchSymbols(query, topK = 5) {
282
+ if (!this.symbolIndexer) return [];
283
+ return this.symbolIndexer.search(query, topK);
284
+ }
285
+
286
+ /**
287
+ * Format symbol search results for the agent.
288
+ */
289
+ formatSymbolResults(results) {
290
+ if (!this.symbolIndexer || !results.length) return '';
291
+ return this.symbolIndexer.formatResults(results);
292
+ }
293
+
294
+ /** Retrieve relevant context chunks for a query, with full text. */
295
+ retrieve(query, topK = 10) {
296
+ if (!this.index) {
297
+ if (!this.loadIndex()) return [];
298
+ }
299
+ const results = this.index.search(query, topK);
300
+ // Attach chunk text to results
301
+ return results.map(r => ({
302
+ ...r,
303
+ text: this.chunkTexts.get(r.id) || `[File: ${r.id}]`,
304
+ }));
305
+ }
306
+
307
+ /** Scan project files respecting .gitignore-like patterns. */
308
+ _scanFiles(dir, depth = 0) {
309
+ if (depth > 15) return [];
310
+ const results = [];
311
+ let entries;
312
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
313
+
314
+ for (const entry of entries) {
315
+ if (entry.name.startsWith('.') && IGNORED_DIRS.has(entry.name)) continue;
316
+ if (IGNORED_DIRS.has(entry.name)) continue;
317
+
318
+ const fullPath = path.join(dir, entry.name);
319
+ if (entry.isDirectory()) {
320
+ results.push(...this._scanFiles(fullPath, depth + 1));
321
+ } else if (entry.isFile()) {
322
+ const ext = path.extname(entry.name);
323
+ if (!CODE_EXTS.has(ext)) continue;
324
+ try {
325
+ const stat = fs.statSync(fullPath);
326
+ if (stat.size > MAX_FILE_SIZE) continue;
327
+ } catch { continue; }
328
+ results.push(fullPath);
329
+ }
330
+ }
331
+ return results;
332
+ }
333
+
334
+ /**
335
+ * Chunk a file by AST boundaries (functions/classes) with line-based fallback.
336
+ * AST-aware chunks contain complete functions/classes — not arbitrary 50-line blocks.
337
+ */
338
+ _chunkFile(content, relPath) {
339
+ const lines = content.split('\n');
340
+
341
+ // Small files: single chunk
342
+ if (lines.length <= CHUNK_LINES) {
343
+ return [{ id: relPath, text: `${relPath}\n${content}` }];
344
+ }
345
+
346
+ // Try AST-aware chunking: split at function/class boundaries
347
+ const boundaries = this._findASTBoundaries(lines);
348
+
349
+ if (boundaries.length > 1) {
350
+ // AST-aware: chunk at function/class boundaries
351
+ const chunks = [];
352
+ for (const { name, startLine, endLine } of boundaries) {
353
+ const chunk = lines.slice(startLine, endLine).join('\n');
354
+ const id = `${relPath}:${startLine + 1}:${name}`;
355
+ chunks.push({ id, text: `${relPath}:${startLine + 1} (${name})\n${chunk}` });
356
+ }
357
+ return chunks;
358
+ }
359
+
360
+ // Fallback: line-based chunking for non-code files
361
+ const chunks = [];
362
+ for (let i = 0; i < lines.length; i += (CHUNK_LINES - CHUNK_OVERLAP)) {
363
+ const chunk = lines.slice(i, i + CHUNK_LINES).join('\n');
364
+ chunks.push({ id: `${relPath}:${i + 1}`, text: `${relPath}:${i + 1}\n${chunk}` });
365
+ }
366
+ return chunks;
367
+ }
368
+
369
+ /**
370
+ * Find function/class boundaries using regex patterns.
371
+ * Returns array of { name, startLine, endLine }.
372
+ */
373
+ _findASTBoundaries(lines) {
374
+ const boundaries = [];
375
+ const patterns = [
376
+ // Python: def/class (indentation-based)
377
+ /^(?:async\s+)?def\s+(\w+)\s*\(/,
378
+ /^class\s+(\w+)/,
379
+ // JS/TS: function, class, export function
380
+ /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/,
381
+ /^(?:export\s+)?class\s+(\w+)/,
382
+ // JS/TS: const fn = arrow
383
+ /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(/,
384
+ // Go: func
385
+ /^func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(/,
386
+ // Rust: fn, struct, impl
387
+ /^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/,
388
+ /^(?:pub\s+)?struct\s+(\w+)/,
389
+ ];
390
+
391
+ let currentStart = 0;
392
+ let currentName = '_top_level';
393
+
394
+ for (let i = 0; i < lines.length; i++) {
395
+ const line = lines[i].trimStart();
396
+ for (const pattern of patterns) {
397
+ const match = line.match(pattern);
398
+ if (match) {
399
+ // Close previous boundary
400
+ if (i > currentStart) {
401
+ boundaries.push({
402
+ name: currentName,
403
+ startLine: currentStart,
404
+ endLine: i,
405
+ });
406
+ }
407
+ currentStart = i;
408
+ currentName = match[1] || match[2] || 'unknown';
409
+ break;
410
+ }
411
+ }
412
+ }
413
+
414
+ // Close last boundary
415
+ if (lines.length > currentStart) {
416
+ boundaries.push({
417
+ name: currentName,
418
+ startLine: currentStart,
419
+ endLine: lines.length,
420
+ });
421
+ }
422
+
423
+ return boundaries;
424
+ }
425
+ }