@axplusb/kepler 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +82 -0
  2. package/package.json +36 -4
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/costs/route.ts +88 -0
  6. package/pulse/app/api/export/route.ts +77 -0
  7. package/pulse/app/api/history/route.ts +11 -0
  8. package/pulse/app/api/import/route.ts +31 -0
  9. package/pulse/app/api/memory/route.ts +52 -0
  10. package/pulse/app/api/plans/route.ts +9 -0
  11. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  12. package/pulse/app/api/projects/route.ts +121 -0
  13. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  14. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  15. package/pulse/app/api/sessions/route.ts +112 -0
  16. package/pulse/app/api/settings/route.ts +14 -0
  17. package/pulse/app/api/stats/route.ts +143 -0
  18. package/pulse/app/api/todos/route.ts +9 -0
  19. package/pulse/app/api/tools/route.ts +160 -0
  20. package/pulse/app/costs/page.tsx +179 -0
  21. package/pulse/app/export/page.tsx +465 -0
  22. package/pulse/app/favicon.ico +0 -0
  23. package/pulse/app/globals.css +263 -0
  24. package/pulse/app/help/page.tsx +142 -0
  25. package/pulse/app/history/page.tsx +157 -0
  26. package/pulse/app/layout.tsx +46 -0
  27. package/pulse/app/memory/page.tsx +365 -0
  28. package/pulse/app/overview-client.tsx +393 -0
  29. package/pulse/app/page.tsx +14 -0
  30. package/pulse/app/plans/page.tsx +308 -0
  31. package/pulse/app/projects/[slug]/page.tsx +390 -0
  32. package/pulse/app/projects/page.tsx +110 -0
  33. package/pulse/app/sessions/[id]/page.tsx +243 -0
  34. package/pulse/app/sessions/page.tsx +39 -0
  35. package/pulse/app/settings/page.tsx +188 -0
  36. package/pulse/app/todos/page.tsx +211 -0
  37. package/pulse/app/tools/page.tsx +249 -0
  38. package/pulse/cli.js +159 -0
  39. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  40. package/pulse/components/activity/streak-card.tsx +36 -0
  41. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  42. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  43. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  44. package/pulse/components/costs/model-token-table.tsx +60 -0
  45. package/pulse/components/global-search.tsx +193 -0
  46. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  47. package/pulse/components/layout/bottom-nav.tsx +52 -0
  48. package/pulse/components/layout/client-layout.tsx +31 -0
  49. package/pulse/components/layout/sidebar-context.tsx +50 -0
  50. package/pulse/components/layout/sidebar.tsx +182 -0
  51. package/pulse/components/layout/top-bar.tsx +121 -0
  52. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  53. package/pulse/components/overview/conversation-table.tsx +148 -0
  54. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  55. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  56. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  57. package/pulse/components/overview/stat-card.tsx +102 -0
  58. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  59. package/pulse/components/projects/project-card.tsx +175 -0
  60. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  61. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  62. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  63. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  64. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  65. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  66. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  67. package/pulse/components/sessions/session-badges.tsx +49 -0
  68. package/pulse/components/sessions/session-table.tsx +299 -0
  69. package/pulse/components/theme-provider.tsx +44 -0
  70. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  71. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  72. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  73. package/pulse/components/tools/version-history-table.tsx +32 -0
  74. package/pulse/components/ui/alert.tsx +66 -0
  75. package/pulse/components/ui/badge.tsx +48 -0
  76. package/pulse/components/ui/breadcrumb.tsx +109 -0
  77. package/pulse/components/ui/button.tsx +64 -0
  78. package/pulse/components/ui/calendar.tsx +220 -0
  79. package/pulse/components/ui/card.tsx +92 -0
  80. package/pulse/components/ui/command.tsx +158 -0
  81. package/pulse/components/ui/dialog.tsx +158 -0
  82. package/pulse/components/ui/input.tsx +21 -0
  83. package/pulse/components/ui/popover.tsx +89 -0
  84. package/pulse/components/ui/progress.tsx +31 -0
  85. package/pulse/components/ui/select.tsx +190 -0
  86. package/pulse/components/ui/separator.tsx +28 -0
  87. package/pulse/components/ui/sheet.tsx +143 -0
  88. package/pulse/components/ui/skeleton.tsx +13 -0
  89. package/pulse/components/ui/table.tsx +116 -0
  90. package/pulse/components/ui/tabs.tsx +91 -0
  91. package/pulse/components/ui/tooltip.tsx +57 -0
  92. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  93. package/pulse/components.json +23 -0
  94. package/pulse/eslint.config.mjs +18 -0
  95. package/pulse/lib/claude-reader.ts +594 -0
  96. package/pulse/lib/decode.ts +129 -0
  97. package/pulse/lib/pricing.ts +102 -0
  98. package/pulse/lib/replay-parser.ts +165 -0
  99. package/pulse/lib/tool-categories.ts +127 -0
  100. package/pulse/lib/utils.ts +6 -0
  101. package/pulse/next-env.d.ts +6 -0
  102. package/pulse/next.config.ts +16 -0
  103. package/pulse/package.json +45 -0
  104. package/pulse/postcss.config.mjs +7 -0
  105. package/pulse/public/activity.png +0 -0
  106. package/pulse/public/cc-lens.png +0 -0
  107. package/pulse/public/command-k.png +0 -0
  108. package/pulse/public/costs.png +0 -0
  109. package/pulse/public/dashboard-dark.png +0 -0
  110. package/pulse/public/dashboard-white.png +0 -0
  111. package/pulse/public/export.png +0 -0
  112. package/pulse/public/file.svg +1 -0
  113. package/pulse/public/globe.svg +1 -0
  114. package/pulse/public/next.svg +1 -0
  115. package/pulse/public/projects.png +0 -0
  116. package/pulse/public/session-chat.png +0 -0
  117. package/pulse/public/todos.png +0 -0
  118. package/pulse/public/tools.png +0 -0
  119. package/pulse/public/vercel.svg +1 -0
  120. package/pulse/public/window.svg +1 -0
  121. package/pulse/tsconfig.json +34 -0
  122. package/pulse/types/claude.ts +294 -0
  123. package/src/agents/loader.mjs +89 -0
  124. package/src/agents/parser.mjs +98 -0
  125. package/src/agents/teams.mjs +123 -0
  126. package/src/auth/oauth.mjs +220 -0
  127. package/src/auth/tarang-auth.mjs +277 -0
  128. package/src/config/cli-args.mjs +173 -0
  129. package/src/config/env.mjs +263 -0
  130. package/src/config/settings.mjs +132 -0
  131. package/src/context/ast-parser.mjs +298 -0
  132. package/src/context/bm25.mjs +85 -0
  133. package/src/context/retriever.mjs +270 -0
  134. package/src/context/skeleton.mjs +134 -0
  135. package/src/core/agent-loop.mjs +480 -0
  136. package/src/core/approval.mjs +273 -0
  137. package/src/core/backend-url.mjs +57 -0
  138. package/src/core/cache.mjs +105 -0
  139. package/src/core/callback-client.mjs +149 -0
  140. package/src/core/checkpoints.mjs +142 -0
  141. package/src/core/context-manager.mjs +198 -0
  142. package/src/core/headless.mjs +168 -0
  143. package/src/core/hooks-manager.mjs +87 -0
  144. package/src/core/jsonl-writer.mjs +351 -0
  145. package/src/core/local-agent.mjs +429 -0
  146. package/src/core/local-store.mjs +325 -0
  147. package/src/core/mode-selector.mjs +51 -0
  148. package/src/core/output-filter.mjs +177 -0
  149. package/src/core/paths.mjs +98 -0
  150. package/src/core/pricing.mjs +314 -0
  151. package/src/core/providers.mjs +219 -0
  152. package/src/core/rate-limiter.mjs +119 -0
  153. package/src/core/safety.mjs +200 -0
  154. package/src/core/scheduler.mjs +173 -0
  155. package/src/core/session-manager.mjs +317 -0
  156. package/src/core/session.mjs +143 -0
  157. package/src/core/settings-sync.mjs +85 -0
  158. package/src/core/stagnation.mjs +57 -0
  159. package/src/core/stream-client.mjs +367 -0
  160. package/src/core/streaming.mjs +182 -0
  161. package/src/core/system-prompt.mjs +135 -0
  162. package/src/core/tool-executor.mjs +725 -0
  163. package/src/hooks/engine.mjs +162 -0
  164. package/src/index.mjs +370 -0
  165. package/src/mcp/client.mjs +253 -0
  166. package/src/mcp/transport-shttp.mjs +130 -0
  167. package/src/mcp/transport-sse.mjs +131 -0
  168. package/src/mcp/transport-ws.mjs +134 -0
  169. package/src/permissions/checker.mjs +57 -0
  170. package/src/permissions/command-classifier.mjs +573 -0
  171. package/src/permissions/injection-check.mjs +60 -0
  172. package/src/permissions/path-check.mjs +102 -0
  173. package/src/permissions/prompt.mjs +73 -0
  174. package/src/permissions/sandbox.mjs +112 -0
  175. package/src/plugins/loader.mjs +138 -0
  176. package/src/skills/loader.mjs +147 -0
  177. package/src/skills/runner.mjs +55 -0
  178. package/src/telemetry/index.mjs +96 -0
  179. package/src/terminal/agents.mjs +177 -0
  180. package/src/terminal/analytics.mjs +292 -0
  181. package/src/terminal/ansi.mjs +421 -0
  182. package/src/terminal/main.mjs +150 -0
  183. package/src/terminal/repl.mjs +1484 -0
  184. package/src/terminal/tool-display.mjs +58 -0
  185. package/src/tools/agent.mjs +137 -0
  186. package/src/tools/ask-user.mjs +61 -0
  187. package/src/tools/bash.mjs +148 -0
  188. package/src/tools/cron-create.mjs +120 -0
  189. package/src/tools/cron-delete.mjs +49 -0
  190. package/src/tools/cron-list.mjs +37 -0
  191. package/src/tools/edit.mjs +82 -0
  192. package/src/tools/enter-worktree.mjs +69 -0
  193. package/src/tools/exit-worktree.mjs +57 -0
  194. package/src/tools/glob.mjs +117 -0
  195. package/src/tools/grep.mjs +129 -0
  196. package/src/tools/lint.mjs +71 -0
  197. package/src/tools/ls.mjs +58 -0
  198. package/src/tools/lsp.mjs +115 -0
  199. package/src/tools/multi-edit.mjs +94 -0
  200. package/src/tools/notebook-edit.mjs +96 -0
  201. package/src/tools/read-mcp-resource.mjs +57 -0
  202. package/src/tools/read.mjs +138 -0
  203. package/src/tools/registry.mjs +132 -0
  204. package/src/tools/remote-trigger.mjs +84 -0
  205. package/src/tools/send-message.mjs +64 -0
  206. package/src/tools/skill.mjs +52 -0
  207. package/src/tools/test-runner.mjs +49 -0
  208. package/src/tools/todo-write.mjs +68 -0
  209. package/src/tools/tool-search.mjs +77 -0
  210. package/src/tools/web-fetch.mjs +65 -0
  211. package/src/tools/web-search.mjs +89 -0
  212. package/src/tools/write.mjs +55 -0
  213. package/src/ui/banner.mjs +237 -0
  214. package/src/ui/commands.mjs +499 -0
  215. package/src/ui/formatter.mjs +379 -0
  216. package/src/ui/markdown.mjs +278 -0
  217. package/src/ui/slash-commands.mjs +258 -0
  218. package/index.js +0 -1
@@ -0,0 +1,725 @@
1
+ /**
2
+ * Tool Executor Bridge — maps Tarang backend tool names to OCC tool calls.
3
+ *
4
+ * The Tarang backend sends tool_request events with its own tool names and arg shapes.
5
+ * This bridge translates those into OCC tool calls and wraps the results.
6
+ *
7
+ * Safety guardrails integrated — prevents destructive operations on source code.
8
+ * 14 tools mapped: 7 OCC-bridged + 7 Tarang-specific.
9
+ */
10
+
11
+ import { createToolRegistry } from '../tools/registry.mjs';
12
+ import { filterOutput } from './output-filter.mjs';
13
+ import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
+ import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
+ import { ContextRetriever } from '../context/retriever.mjs';
16
+ import { analyzeCode } from '../context/ast-parser.mjs';
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { execSync } from 'node:child_process';
20
+
21
+ /**
22
+ * Create a tool executor that bridges Tarang tool names to OCC tools.
23
+ * @param {Object} [options]
24
+ * @param {ContextRetriever} [options.retriever] - BM25 retriever for search_code
25
+ * @returns {{ execute(name, args): Promise<Object>, listTools(): string[] }}
26
+ */
27
+ export function createToolExecutor({ retriever } = {}) {
28
+ const occRegistry = createToolRegistry();
29
+ let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
30
+
31
+ // Capture CWD at creation time — OCC Bash tool mutates process.cwd() after shell commands
32
+ const PROJECT_CWD = process.cwd();
33
+
34
+ /**
35
+ * Resolve a path relative to project CWD, with traversal protection.
36
+ */
37
+ function resolvePath(p) {
38
+ if (!p) return PROJECT_CWD;
39
+ const resolved = path.resolve(PROJECT_CWD, p);
40
+ // Prevent path traversal outside CWD's parent
41
+ const cwd = process.cwd();
42
+ const cwdParent = path.dirname(cwd);
43
+ if (!resolved.startsWith(cwdParent)) {
44
+ throw new Error(`Path traversal blocked: ${p}`);
45
+ }
46
+ return resolved;
47
+ }
48
+
49
+ /**
50
+ * Detect if an OCC tool result string indicates an error.
51
+ */
52
+ function isError(result) {
53
+ if (typeof result !== 'string') return false;
54
+ return result.startsWith('Error:') || result.startsWith('Error -') ||
55
+ result.includes('Exit code:') && !result.includes('Exit code: 0');
56
+ }
57
+
58
+ /**
59
+ * Wrap an OCC string result into Tarang's { success, output } format.
60
+ */
61
+ function wrapResult(result, toolName) {
62
+ if (typeof result === 'object' && result !== null && 'success' in result) {
63
+ result._tool = toolName;
64
+ return result;
65
+ }
66
+ const output = typeof result === 'string' ? result : JSON.stringify(result);
67
+ return {
68
+ success: !isError(output),
69
+ output,
70
+ _tool: toolName,
71
+ };
72
+ }
73
+
74
+ // ── Auto-lint after file writes ────────────────────────────
75
+
76
+ const LINT_COMMANDS = {
77
+ '.py': (file) => `python3 -m py_compile "${file}" 2>&1`,
78
+ '.js': (file) => `npx eslint --no-eslintrc --rule '{}' "${file}" 2>&1 || true`,
79
+ '.ts': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
80
+ '.tsx': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
81
+ '.go': (file) => `go vet "${file}" 2>&1`,
82
+ '.rs': (file) => `rustfmt --check "${file}" 2>&1`,
83
+ };
84
+
85
+ function autoLint(filePath) {
86
+ const ext = path.extname(filePath);
87
+ const cmdFn = LINT_COMMANDS[ext];
88
+ if (!cmdFn) return null;
89
+
90
+ try {
91
+ const output = execSync(cmdFn(filePath), {
92
+ encoding: 'utf-8',
93
+ timeout: 15_000,
94
+ cwd: process.cwd(),
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+ const trimmed = output.trim();
98
+ if (!trimmed) return null;
99
+ return trimmed;
100
+ } catch (err) {
101
+ // Non-zero exit means lint errors found
102
+ const output = (err.stderr || err.stdout || '').trim();
103
+ if (!output) return null;
104
+ return output;
105
+ }
106
+ }
107
+
108
+ // ── Tool mapping table ──────────────────────────────────────
109
+
110
+ const toolMap = {
111
+ // 1. shell → Bash + classification + smart output filtering
112
+ shell: async (args) => {
113
+ // Phase 1: legacy safety check (kept for backward compat)
114
+ const shellCheck = validateShellCommand(args.command);
115
+ if (!shellCheck.safe) {
116
+ return {
117
+ success: false,
118
+ output: `BLOCKED: ${shellCheck.reason}. Your current working directory is ${process.cwd()} — search within it, not from filesystem root.`,
119
+ _tool: 'shell', _blocked: true,
120
+ };
121
+ }
122
+
123
+ // Phase 2: command classifier (PRD-050)
124
+ const classification = classifyCommand(args.command);
125
+ if (classification.classification === 'blocked') {
126
+ return {
127
+ success: false,
128
+ output: `BLOCKED: ${classification.reason}`,
129
+ _tool: 'shell', _blocked: true,
130
+ };
131
+ }
132
+
133
+ // Tag for approval/sandbox routing
134
+ if (classification.highRisk || shellCheck.highRisk) {
135
+ args._highRisk = true;
136
+ args._riskReason = classification.reason || shellCheck.reason;
137
+ }
138
+ args._classification = classification.classification; // 'safe' or 'contained'
139
+
140
+ // Pre-check: if command is rm/unlink, verify targets exist first
141
+ const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
142
+ if (rmMatch) {
143
+ const targets = rmMatch[1].split(/\s+/).filter(t => !t.startsWith('-'));
144
+ const missing = targets.filter(t => {
145
+ try { return !fs.existsSync(path.resolve(process.cwd(), t)); } catch { return true; }
146
+ });
147
+ if (missing.length > 0 && missing.length === targets.length) {
148
+ return {
149
+ success: true,
150
+ output: `No action needed: ${missing.join(', ')} — file(s) do not exist. Do not retry.`,
151
+ exit_code: 0,
152
+ _tool: 'shell',
153
+ _skipped: true,
154
+ };
155
+ }
156
+ }
157
+
158
+ const result = await occRegistry.call('Bash', {
159
+ command: args.command,
160
+ timeout: args.timeout,
161
+ description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
162
+ });
163
+ const rawOutput = typeof result === 'string' ? result : String(result);
164
+ const exitMatch = rawOutput.match(/Exit code: (\d+)/);
165
+ const exitCode = exitMatch ? parseInt(exitMatch[1]) : 0;
166
+ // Semantic exit code: grep returns 1 for "no matches" (not an error)
167
+ const success = !isExitCodeError(args.command, exitCode);
168
+
169
+ // Apply smart filtering based on command type
170
+ const filtered = filterOutput(rawOutput, args.command, success);
171
+
172
+ return {
173
+ success,
174
+ output: filtered.output,
175
+ exit_code: exitCode,
176
+ _tool: 'shell',
177
+ _classification: args._classification,
178
+ _commandType: filtered.commandType,
179
+ _filtered: filtered.truncated || filtered.originalLines !== filtered.filteredLines,
180
+ };
181
+ },
182
+
183
+ // 2. read_file → Read (with smart truncation for large files)
184
+ read_file: async (args) => {
185
+ const filePath = resolvePath(args.file_path || args.path);
186
+ const hasLineRange = args.start_line || args.end_line || args.offset || args.limit;
187
+
188
+ // Nudge: if reading shallow overview files, remind agent to search deeper
189
+ const basename = path.basename(filePath).toLowerCase();
190
+ const isShallowFile = ['readme.md', 'package.json', 'pyproject.toml', 'cargo.toml', 'go.mod'].includes(basename);
191
+ const nudge = isShallowFile && !_searchCodeUsed
192
+ ? '\n\nNOTE: You read a top-level overview file. Use search_code(query) to find actual implementations before drawing conclusions. READMEs and package.json do NOT show what features exist in the codebase.'
193
+ : '';
194
+
195
+ // If no line range specified, auto-truncate and return AST summary
196
+ if (!hasLineRange) {
197
+ try {
198
+ const content = fs.readFileSync(filePath, 'utf-8');
199
+ const lines = content.split('\n').length;
200
+
201
+ if (lines > 50) {
202
+ // File >50 lines: return AST summary with line numbers
203
+ // Model must use start_line/end_line to read specific sections
204
+ const analysis = analyzeCode(filePath);
205
+ const firstLines = content.split('\n').slice(0, 20).join('\n');
206
+ return {
207
+ success: true,
208
+ output: `${analysis.summary}\n\n` +
209
+ `## First 20 lines\n${firstLines}${nudge}`,
210
+ _tool: 'read_file',
211
+ _truncated: true,
212
+ _total_lines: lines,
213
+ };
214
+ }
215
+ // Small file (<50 lines): return full content
216
+ } catch { /* let Read handle the error */ }
217
+ }
218
+
219
+ // Convert start_line/end_line to offset/limit
220
+ const offset = args.start_line ? args.start_line - 1 : args.offset;
221
+ const limit = (args.start_line && args.end_line)
222
+ ? (args.end_line - args.start_line + 1)
223
+ : args.limit;
224
+
225
+ const result = await occRegistry.call('Read', {
226
+ file_path: filePath,
227
+ offset,
228
+ limit,
229
+ });
230
+ const output = typeof result === 'string' ? result : String(result);
231
+ const content = output.replace(/^\s*\d+[→\t]/gm, '');
232
+ return {
233
+ success: !isError(output),
234
+ content,
235
+ output: output + nudge,
236
+ _tool: 'read_file',
237
+ _output_type: 'file_content',
238
+ };
239
+ },
240
+
241
+ // 3. write_file → Write + auto-lint + safety check
242
+ write_file: async (args) => {
243
+ const rawPath = args.file_path || args.path;
244
+ if (!rawPath || rawPath === 'file' || rawPath.length < 3) {
245
+ return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Use an ABSOLUTE path like "${process.cwd()}/src/main.py"`, _tool: 'write_file' };
246
+ }
247
+ const filePath = resolvePath(rawPath);
248
+ const writeCheck = validateWrite(filePath, args.content);
249
+ if (!writeCheck.safe) {
250
+ return { success: false, output: `🛡️ BLOCKED: ${writeCheck.reason}`, _tool: 'write_file', _blocked: true };
251
+ }
252
+ // OCC Write requires Read first for existing files — handle gracefully
253
+ try {
254
+ if (fs.existsSync(filePath)) {
255
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
256
+ }
257
+ } catch { /* file may not exist yet */ }
258
+ const result = await occRegistry.call('Write', {
259
+ file_path: filePath,
260
+ content: args.content,
261
+ });
262
+ const wrapped = wrapResult(result, 'write_file');
263
+
264
+ // Auto-lint the written file
265
+ const lintOutput = autoLint(filePath);
266
+ if (lintOutput) {
267
+ wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
268
+ wrapped.lint = lintOutput;
269
+ }
270
+
271
+ return wrapped;
272
+ },
273
+
274
+ // 3b. write_project → Batch write multiple files at once
275
+ write_project: async (args) => {
276
+ const files = args.files || [];
277
+ if (!files.length) {
278
+ return { success: false, output: 'Error: No files provided', _tool: 'write_project' };
279
+ }
280
+
281
+ const results = [];
282
+ const errors = [];
283
+
284
+ for (const file of files) {
285
+ const rawPath = file.path || file.file_path;
286
+ if (!rawPath) {
287
+ errors.push('Missing path in file entry');
288
+ continue;
289
+ }
290
+ const filePath = resolvePath(rawPath);
291
+ const content = file.content || '';
292
+
293
+ const writeCheck = validateWrite(filePath, content);
294
+ if (!writeCheck.safe) {
295
+ errors.push(`${rawPath}: BLOCKED — ${writeCheck.reason}`);
296
+ continue;
297
+ }
298
+
299
+ try {
300
+ // Ensure parent directory exists
301
+ const dir = path.dirname(filePath);
302
+ fs.mkdirSync(dir, { recursive: true });
303
+
304
+ // Read first if exists (OCC Write requirement)
305
+ try {
306
+ if (fs.existsSync(filePath)) {
307
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
308
+ }
309
+ } catch { /* file may not exist yet */ }
310
+
311
+ await occRegistry.call('Write', { file_path: filePath, content });
312
+ results.push(rawPath);
313
+ } catch (err) {
314
+ errors.push(`${rawPath}: ${err.message}`);
315
+ }
316
+ }
317
+
318
+ const output = results.length > 0
319
+ ? `Created ${results.length} file(s):\n${results.map(f => ` ✓ ${f}`).join('\n')}`
320
+ : 'No files written';
321
+
322
+ if (errors.length > 0) {
323
+ return {
324
+ success: results.length > 0,
325
+ output: `${output}\n\nErrors:\n${errors.map(e => ` ✗ ${e}`).join('\n')}`,
326
+ files_written: results,
327
+ files_failed: errors,
328
+ _tool: 'write_project',
329
+ };
330
+ }
331
+
332
+ return { success: true, output, files_written: results, _tool: 'write_project' };
333
+ },
334
+
335
+ // 4. edit_file → Edit + auto-lint + auto-fallback to sed
336
+ edit_file: async (args) => {
337
+ const rawPath = args.file_path || args.path;
338
+ const filePath = resolvePath(rawPath);
339
+ // OCC Edit requires Read first
340
+ try {
341
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
342
+ } catch { /* best effort */ }
343
+
344
+ let result;
345
+ try {
346
+ result = await occRegistry.call('Edit', {
347
+ file_path: filePath,
348
+ old_string: args.search,
349
+ new_string: args.replace,
350
+ replace_all: args.replace_all || false,
351
+ });
352
+ } catch (editErr) {
353
+ // OCC Edit failed (string not found) — fallback to Python replacement
354
+ try {
355
+ const search = args.search.replace(/'/g, "\\'").replace(/\n/g, "\\n");
356
+ const replace = args.replace.replace(/'/g, "\\'").replace(/\n/g, "\\n");
357
+ const pyCmd = `python3 -c "
358
+ import sys
359
+ with open('${filePath}', 'r') as f: content = f.read()
360
+ old = '''${args.search}'''
361
+ new = '''${args.replace}'''
362
+ if old not in content:
363
+ print('ERROR: search string not found in file', file=sys.stderr)
364
+ sys.exit(1)
365
+ content = content.replace(old, new, 1)
366
+ with open('${filePath}', 'w') as f: f.write(content)
367
+ print('OK: replaced')
368
+ "`;
369
+ const fallbackResult = execSync(pyCmd, { encoding: 'utf-8', timeout: 5000, cwd: PROJECT_CWD });
370
+ result = `Edited ${filePath} (via fallback): ${fallbackResult.trim()}`;
371
+ } catch (sedErr) {
372
+ return { success: false, output: `edit_file failed: ${editErr?.message || 'unknown'}. Fallback also failed: ${sedErr?.message || 'unknown'}. Try shell(sed) manually.`, _tool: 'edit_file' };
373
+ }
374
+ }
375
+
376
+ const wrapped = wrapResult(result, 'edit_file');
377
+
378
+ // Auto-lint the edited file
379
+ const lintOutput = autoLint(filePath);
380
+ if (lintOutput) {
381
+ wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
382
+ wrapped.lint = lintOutput;
383
+ }
384
+
385
+ return wrapped;
386
+ },
387
+
388
+ // 5. list_files → Glob
389
+ list_files: async (args) => {
390
+ const result = await occRegistry.call('Glob', {
391
+ pattern: args.pattern || '**/*',
392
+ path: args.path ? resolvePath(args.path) : undefined,
393
+ });
394
+ const output = typeof result === 'string' ? result : String(result);
395
+ const files = output.split('\n').filter(Boolean);
396
+ return {
397
+ success: true,
398
+ files,
399
+ output,
400
+ _tool: 'list_files',
401
+ };
402
+ },
403
+
404
+ // 6. search_code → combined rg + BM25 for best results
405
+ search_code: async (args) => {
406
+ _searchCodeUsed = true;
407
+ const query = args.query || args.pattern;
408
+ if (!query) return { success: false, output: 'query required', _tool: 'search_code' };
409
+
410
+ const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
411
+ const parts = [];
412
+
413
+ // Layer 1: ripgrep — exact text matches with context
414
+ try {
415
+ const cmd = `rg -n -C 1 --max-count 5 --max-filesize 500K -e ${JSON.stringify(query)} ${JSON.stringify(searchPath)} 2>/dev/null | head -60`;
416
+ const rgOutput = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: searchPath }).trim();
417
+ if (rgOutput) {
418
+ parts.push(`## Exact matches (rg)\n${rgOutput}`);
419
+ }
420
+ } catch { /* rg not found or no results */ }
421
+
422
+ // Layer 2: BM25 — semantic relevance (finds related code even without exact match)
423
+ if (retriever) {
424
+ if (!retriever.index) retriever.loadIndex();
425
+ const chunks = retriever.retrieve(query, 5);
426
+ if (chunks.length > 0) {
427
+ const bm25Output = chunks.map(c => {
428
+ const score = c.score?.toFixed(2) || '?';
429
+ return `── ${c.id} (score: ${score}) ──\n${c.text}`;
430
+ }).join('\n\n');
431
+ parts.push(`## Related code (BM25)\n${bm25Output}`);
432
+ }
433
+ }
434
+
435
+ // Return combined results
436
+ if (parts.length > 0) {
437
+ return {
438
+ success: true,
439
+ output: parts.join('\n\n'),
440
+ _tool: 'search_code',
441
+ _method: parts.length > 1 ? 'rg+bm25' : (parts[0].startsWith('## Exact') ? 'rg' : 'bm25'),
442
+ };
443
+ }
444
+
445
+ // Nothing found — actionable hint
446
+ const firstWord = query.split(/\s+/)[0];
447
+ return {
448
+ success: true,
449
+ output: `No results for "${query}" in ${searchPath}.\n` +
450
+ `Try: shell(grep -rn "${firstWord}" . --include="*.py" | head -20)`,
451
+ _tool: 'search_code',
452
+ _method: 'none',
453
+ };
454
+ },
455
+
456
+ // 7. search_files → Grep with line numbers + context (like grep -n -C 3)
457
+ search_files: async (args) => {
458
+ const query = args.query || args.pattern || '*';
459
+
460
+ // If it looks like a glob pattern, use Glob
461
+ if (query.includes('*') || query.includes('?')) {
462
+ const result = await occRegistry.call('Glob', {
463
+ pattern: query,
464
+ path: args.path ? resolvePath(args.path) : undefined,
465
+ });
466
+ const output = typeof result === 'string' ? result : String(result);
467
+ return {
468
+ success: true,
469
+ files: output.split('\n').filter(Boolean),
470
+ output,
471
+ _tool: 'search_files',
472
+ };
473
+ }
474
+
475
+ // For text patterns: grep with context lines (like grep -n -C 3)
476
+ const result = await occRegistry.call('Grep', {
477
+ pattern: query,
478
+ path: args.path ? resolvePath(args.path) : undefined,
479
+ output_mode: 'content',
480
+ '-n': true,
481
+ '-C': 3,
482
+ head_limit: 50,
483
+ });
484
+ const output = typeof result === 'string' ? result : String(result);
485
+ return {
486
+ success: true,
487
+ files: output.split('\n').filter(Boolean),
488
+ output,
489
+ _tool: 'search_files',
490
+ };
491
+ },
492
+
493
+ // 7b. grep → dedicated ripgrep tool (fast text/regex search)
494
+ grep: async (args) => {
495
+ const pattern = args.pattern;
496
+ if (!pattern) return { success: false, output: 'pattern required', _tool: 'grep' };
497
+
498
+ const searchPath = args.path ? resolvePath(args.path) : PROJECT_CWD;
499
+ const includeFlag = args.include ? `--glob "${args.include}"` : '';
500
+
501
+ try {
502
+ const cmd = `rg -n -C 2 --max-count 10 --max-filesize 500K ${includeFlag} -e ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} 2>/dev/null | head -80`;
503
+ const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: PROJECT_CWD }).trim();
504
+ if (output) {
505
+ return { success: true, output, _tool: 'grep' };
506
+ }
507
+ } catch { /* no results or rg not found */ }
508
+
509
+ return {
510
+ success: true,
511
+ output: `No matches for "${pattern}" in ${searchPath}`,
512
+ _tool: 'grep',
513
+ };
514
+ },
515
+
516
+ // ── Tarang-specific tools (no OCC bridge) ──────────────
517
+
518
+ // 8. read_files → batch Read (with AST truncation for large files)
519
+ read_files: async (args) => {
520
+ const paths = args.file_paths || args.paths || [];
521
+ const results = [];
522
+ for (const p of paths) {
523
+ try {
524
+ const filePath = resolvePath(p);
525
+ const content = fs.readFileSync(filePath, 'utf-8');
526
+ const lines = content.split('\n').length;
527
+
528
+ if (lines > 50) {
529
+ // Large file: return AST summary instead of full content
530
+ const analysis = analyzeCode(filePath);
531
+ results.push({
532
+ path: p, lines,
533
+ content: analysis.summary,
534
+ _truncated: true,
535
+ success: true,
536
+ });
537
+ } else {
538
+ results.push({ path: p, content, success: true });
539
+ }
540
+ } catch (err) {
541
+ results.push({ path: p, error: err.message, success: false });
542
+ }
543
+ }
544
+ return { success: true, files: results, _tool: 'read_files' };
545
+ },
546
+
547
+ // 9. delete_file + safety check
548
+ delete_file: async (args) => {
549
+ try {
550
+ const filePath = resolvePath(args.file_path || args.path);
551
+ const delCheck = validateDelete(filePath);
552
+ if (!delCheck.safe) {
553
+ return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
554
+ }
555
+ fs.unlinkSync(filePath);
556
+ return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
557
+ } catch (err) {
558
+ return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
559
+ }
560
+ },
561
+
562
+ // 10. get_file_info
563
+ get_file_info: async (args) => {
564
+ try {
565
+ const filePath = resolvePath(args.file_path || args.path);
566
+ const stat = fs.statSync(filePath);
567
+ return {
568
+ success: true,
569
+ size: stat.size,
570
+ mtime: stat.mtime.toISOString(),
571
+ type: stat.isDirectory() ? 'directory' : 'file',
572
+ mode: stat.mode.toString(8),
573
+ _tool: 'get_file_info',
574
+ };
575
+ } catch (err) {
576
+ return { success: false, output: `Error: ${err.message}`, _tool: 'get_file_info' };
577
+ }
578
+ },
579
+
580
+ // 11. validate_file (syntax check)
581
+ validate_file: async (args) => {
582
+ try {
583
+ const filePath = resolvePath(args.path);
584
+ const ext = path.extname(filePath);
585
+ let cmd;
586
+ if (ext === '.py') cmd = `python3 -m py_compile "${filePath}"`;
587
+ else if (ext === '.js' || ext === '.mjs') cmd = `node --check "${filePath}"`;
588
+ else return { success: true, valid: true, message: 'No validator for this file type', _tool: 'validate_file' };
589
+
590
+ execSync(cmd, { stdio: 'pipe' });
591
+ return { success: true, valid: true, _tool: 'validate_file' };
592
+ } catch (err) {
593
+ return { success: true, valid: false, errors: err.stderr?.toString() || err.message, _tool: 'validate_file' };
594
+ }
595
+ },
596
+
597
+ // 12. validate_build
598
+ validate_build: async (args) => {
599
+ try {
600
+ let cmd = args.command;
601
+ if (!cmd) {
602
+ if (fs.existsSync('package.json')) cmd = 'npm run build';
603
+ else if (fs.existsSync('Makefile')) cmd = 'make';
604
+ else if (fs.existsSync('Cargo.toml')) cmd = 'cargo build';
605
+ else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
606
+ }
607
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 120_000 }).toString();
608
+ return { success: true, output, _tool: 'validate_build' };
609
+ } catch (err) {
610
+ return { success: false, output: err.stderr?.toString() || err.message, _tool: 'validate_build' };
611
+ }
612
+ },
613
+
614
+ // 13. validate_structure
615
+ validate_structure: async (args) => {
616
+ const expected = args.expected || [];
617
+ const missing = expected.filter(f => !fs.existsSync(resolvePath(f)));
618
+ return {
619
+ success: missing.length === 0,
620
+ missing,
621
+ checked: expected.length,
622
+ _tool: 'validate_structure',
623
+ };
624
+ },
625
+
626
+ // 14. lint_check
627
+ lint_check: async (args) => {
628
+ try {
629
+ const filePath = resolvePath(args.file_path || args.path);
630
+ const ext = path.extname(filePath);
631
+ let cmd;
632
+ if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
633
+ else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
634
+ else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
635
+
636
+ const output = execSync(cmd, { stdio: 'pipe', timeout: 30_000 }).toString();
637
+ return { success: true, output, issues: output.split('\n').filter(Boolean), _tool: 'lint_check' };
638
+ } catch (err) {
639
+ return { success: false, output: err.message, _tool: 'lint_check' };
640
+ }
641
+ },
642
+
643
+ // 15. run_tests
644
+ run_tests: async (args) => {
645
+ try {
646
+ const cmd = args.command || 'npm test';
647
+ const output = execSync(cmd, {
648
+ stdio: 'pipe', timeout: 120_000, cwd: process.cwd(),
649
+ encoding: 'utf-8',
650
+ }).toString();
651
+ return { success: true, output: output.slice(-3000), _tool: 'run_tests' };
652
+ } catch (err) {
653
+ const output = (err.stdout || '') + (err.stderr || '');
654
+ return { success: false, output: output.slice(-3000), exit_code: err.status, _tool: 'run_tests' };
655
+ }
656
+ },
657
+
658
+ // 16. git_diff
659
+ git_diff: async (args) => {
660
+ try {
661
+ const filePath = args.file_path ? `-- "${args.file_path}"` : '';
662
+ const output = execSync(`git diff ${filePath}`, {
663
+ stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
664
+ }).toString();
665
+ return { success: true, output: output.slice(-5000) || '(no changes)', _tool: 'git_diff' };
666
+ } catch (err) {
667
+ return { success: false, output: err.message, _tool: 'git_diff' };
668
+ }
669
+ },
670
+
671
+ // 17. git_status
672
+ git_status: async (args) => {
673
+ try {
674
+ const output = execSync('git status --short', {
675
+ stdio: 'pipe', timeout: 10_000, cwd: process.cwd(), encoding: 'utf-8',
676
+ }).toString();
677
+ return { success: true, output: output || '(clean)', _tool: 'git_status' };
678
+ } catch (err) {
679
+ return { success: false, output: err.message, _tool: 'git_status' };
680
+ }
681
+ },
682
+
683
+ // 18. analyze_code — AST-based structured code analysis
684
+ // Returns function signatures, classes, imports instead of raw file contents
685
+ // 10x more token-efficient than read_file
686
+ analyze_code: async (args) => {
687
+ const filePath = resolvePath(args.file_path || args.path);
688
+ const result = analyzeCode(filePath, {
689
+ startLine: args.start_line,
690
+ endLine: args.end_line,
691
+ });
692
+ return {
693
+ success: result.success,
694
+ output: result.summary,
695
+ structure: result.structure,
696
+ _tool: 'analyze_code',
697
+ };
698
+ },
699
+ };
700
+
701
+ return {
702
+ /**
703
+ * Execute a Tarang tool by name.
704
+ * @param {string} name - Tarang tool name
705
+ * @param {Object} args - Tool arguments
706
+ * @returns {Promise<Object>} - { success, output, ... }
707
+ */
708
+ async execute(name, args) {
709
+ const handler = toolMap[name];
710
+ if (!handler) {
711
+ return { success: false, output: `Unknown tool: ${name}`, _tool: name };
712
+ }
713
+ try {
714
+ return await handler(args);
715
+ } catch (err) {
716
+ return { success: false, output: `Tool error (${name}): ${err.message}`, _tool: name };
717
+ }
718
+ },
719
+
720
+ /** List all available tool names. */
721
+ listTools() {
722
+ return Object.keys(toolMap);
723
+ },
724
+ };
725
+ }