headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AIRunner embedder for the codebase index.
3
+ *
4
+ * A third backend alongside the OpenRouter (src/codesearch/embedder.ts) and
5
+ * Ollama (src/codesearch/ollama-embedder.ts) embedders. AIRunner (the local
6
+ * FastAPI/Python server in the sibling `airunner` repo) exposes its native
7
+ * in-process embedding stack (intfloat/e5-large) through a plain HTTP
8
+ * endpoint — POST /api/v1/embed/text — so headlesscode can embed chunks
9
+ * against a locally running AIRunner server without going through the
10
+ * Ollama-shaped shim or spending cloud money.
11
+ *
12
+ * Opt-in via `HEADLESSCODE_EMBEDDING_BACKEND=airunner` or
13
+ * `--embedding-backend airunner` on `headlesscode index`; the default stays
14
+ * OpenRouter.
15
+ *
16
+ * ─── AIRunner embeddings — endpoint contract ────────────────────────────
17
+ *
18
+ * 1. ENDPOINT + BATCHING: POST {AIRUNNER_EMBED_URL}/api/v1/embed/text
19
+ * accepts `{"texts": string[]}` and returns
20
+ * `{"model": "intfloat/e5-large", "embeddings": number[][]}` — one HTTP
21
+ * call per whole batch, vectors in input order (mirrors the Ollama
22
+ * /api/embed contract). e5-large produces 1024-dim vectors.
23
+ * 2. RESPONSE SHAPE: embeddings are plain JSON number arrays. There is no
24
+ * usage/token accounting for a fully local call — promptTokens is
25
+ * approximate (vector length) and only used for display, exactly like the
26
+ * Ollama backend.
27
+ * 3. DIMENSION: e5-large = 1024 dims, NOT interchangeable with Ollama's
28
+ * qwen3-embedding:8b (4096) or OpenRouter's qwen/qwen3-embedding-4b
29
+ * (2560). The index build records {backend, model} metadata and the
30
+ * `codebase_search` tool refuses a backend mismatch (see
31
+ * src/codesearch/index.ts + the executor handler), same as the other
32
+ * local backend.
33
+ *
34
+ * The embedder interface (Embedder) is shared with the other paths; only the
35
+ * HTTP client differs. No batching/hashing logic is duplicated here — the
36
+ * caller (src/codesearch/index.ts buildIndex) slices batches exactly as it
37
+ * does for the other backends.
38
+ */
39
+
40
+ import type { EmbedResult } from "./embedder.js"
41
+
42
+ /** Default AIRunner server base URL (AIRunner's standard local port). */
43
+ export const DEFAULT_AIRUNNER_URL = "http://localhost:8080"
44
+
45
+ /** Default local embedding model (the one AIRunner serves). */
46
+ export const DEFAULT_AIRUNNER_EMBEDDING_MODEL = "intfloat/e5-large"
47
+
48
+ /** Env var that overrides the AIRunner server URL. */
49
+ export const AIRUNNER_URL_ENV = "HEADLESSCODE_AIRUNNER_EMBED_URL"
50
+
51
+ /** Env var that overrides the AIRunner embedding model name. */
52
+ export const AIRUNNER_EMBEDDING_MODEL_ENV = "HEADLESSCODE_AIRUNNER_EMBED_MODEL"
53
+
54
+ /** Resolve the AIRunner URL: env override → default. */
55
+ export function resolveAirunnerUrl(env: NodeJS.ProcessEnv = process.env): string {
56
+ return env[AIRUNNER_URL_ENV]?.trim() || DEFAULT_AIRUNNER_URL
57
+ }
58
+
59
+ /** Resolve the AIRunner embedding model: env override → default. */
60
+ export function resolveAirunnerEmbeddingModel(env: NodeJS.ProcessEnv = process.env): string {
61
+ return env[AIRUNNER_EMBEDDING_MODEL_ENV]?.trim() || DEFAULT_AIRUNNER_EMBEDDING_MODEL
62
+ }
63
+
64
+ /** Typed error for AIRunner failures, carrying a pre-built actionable message. */
65
+ export class AirunnerEmbedError extends Error {
66
+ /** HTTP status when AIRunner answered (undefined for network-level failures). */
67
+ readonly status?: number
68
+ /** The raw error body AIRunner returned, when one exists. */
69
+ readonly body?: string
70
+
71
+ constructor(message: string, status?: number, body?: string) {
72
+ super(message)
73
+ this.name = "AirunnerEmbedError"
74
+ this.status = status
75
+ this.body = body
76
+ }
77
+ }
78
+
79
+ /**
80
+ * AIRunner-backed embedder. One HTTP call per batch (POST /api/v1/embed/text),
81
+ * matching the other backends' batching. Failures are wrapped in actionable
82
+ * errors — never a silent fallback to another backend (that would surprise
83
+ * the user with unexpected cloud spend).
84
+ */
85
+ export class AirunnerEmbedder {
86
+ readonly backend = "airunner" as const
87
+ readonly model: string
88
+ private readonly baseUrl: string
89
+
90
+ constructor(model: string = resolveAirunnerEmbeddingModel(), baseUrl: string = resolveAirunnerUrl()) {
91
+ this.model = model
92
+ this.baseUrl = baseUrl.replace(/\/+$/, "")
93
+ }
94
+
95
+ async embedBatch(texts: string[]): Promise<EmbedResult> {
96
+ if (texts.length === 0) {
97
+ return { embeddings: [], model: this.model, promptTokens: 0, totalTokens: 0 }
98
+ }
99
+
100
+ // Same guard as the other embedders: empty inputs are rejected
101
+ // upstream and produce no useful vector anyway.
102
+ const emptyIndex = texts.findIndex((t) => t.trim() === "")
103
+ if (emptyIndex !== -1) {
104
+ throw new AirunnerEmbedError(
105
+ `cannot embed empty/whitespace-only input at index ${emptyIndex} — providers reject it`,
106
+ )
107
+ }
108
+
109
+ const url = `${this.baseUrl}/api/v1/embed/text`
110
+ let response: Response
111
+ try {
112
+ response = await fetch(url, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ texts }),
116
+ })
117
+ } catch (err) {
118
+ // Network-level failure (connection refused, DNS, ...) — AIRunner
119
+ // is either not running or unreachable. Actionable, not generic.
120
+ throw new AirunnerEmbedError(
121
+ `AIRunner backend selected but ${this.baseUrl} is unreachable — is the AIRunner server running? ` +
122
+ `(or set HEADLESSCODE_AIRUNNER_EMBED_URL if AIRunner listens elsewhere): ` +
123
+ `${err instanceof Error ? err.message : String(err)}`,
124
+ )
125
+ }
126
+
127
+ const rawBody = await response.text().catch(() => "")
128
+ if (!response.ok) {
129
+ throw new AirunnerEmbedError(
130
+ `AIRunner embeddings returned HTTP ${response.status}: ${excerpt(rawBody || "(empty body)")}`,
131
+ response.status,
132
+ excerpt(rawBody),
133
+ )
134
+ }
135
+
136
+ let data:
137
+ | {
138
+ model?: unknown
139
+ embeddings?: unknown
140
+ error?: { message?: string }
141
+ }
142
+ | undefined
143
+ try {
144
+ data = JSON.parse(rawBody)
145
+ } catch {
146
+ throw new AirunnerEmbedError(
147
+ `AIRunner embeddings returned HTTP 200 with a non-JSON/unparseable body: ${excerpt(rawBody || "(empty)")}`,
148
+ )
149
+ }
150
+
151
+ if (!Array.isArray(data?.embeddings) || data.embeddings.length !== texts.length) {
152
+ const n = Array.isArray(data?.embeddings) ? data.embeddings.length : 0
153
+ throw new AirunnerEmbedError(
154
+ `AIRunner embeddings response contained ${n} embeddings for ${texts.length} inputs. ` +
155
+ `Raw body: ${excerpt(rawBody || "(empty)")}`,
156
+ )
157
+ }
158
+
159
+ // Validate every entry is a numeric array — garbage here would silently
160
+ // poison the index (dimension is whatever the model returns, so the
161
+ // per-vector length must at least be self-consistent).
162
+ const embeddings: number[][] = []
163
+ let promptTokens = 0
164
+ for (const item of data.embeddings) {
165
+ if (!Array.isArray(item) || item.some((v) => typeof v !== "number")) {
166
+ throw new AirunnerEmbedError(
167
+ `AIRunner embeddings response contained a non-numeric embedding vector. Raw body: ${excerpt(rawBody)}`,
168
+ )
169
+ }
170
+ embeddings.push(item as number[])
171
+ promptTokens += (item as number[]).length
172
+ }
173
+
174
+ // No usage/cost to report for a fully local call — promptTokens is
175
+ // approximate (vector length, not true token count) and only used for
176
+ // display; totalTokens mirrors it so accounting call sites never see
177
+ // NaN/undefined.
178
+ return { embeddings, model: this.model, promptTokens, totalTokens: promptTokens }
179
+ }
180
+ }
181
+
182
+ /** Truncate a raw body to a bounded excerpt for error messages. */
183
+ function excerpt(body: string): string {
184
+ return body.length > 500 ? `${body.slice(0, 500)}…` : body
185
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Source-file chunking for the codebase index.
3
+ *
4
+ * Splits a source file into chunks that (a) are small enough to embed usefully
5
+ * and (b) map back to a `file:startLine-endLine` reference so a search result
6
+ * can be cited to the model.
7
+ *
8
+ * Strategy (deliberately heuristic, NOT a full per-language parser — the spec
9
+ * says good-enough regex/indentation beats over-engineering language-aware
10
+ * parsing, and the reference upstream implementation uses tree-sitter which is
11
+ * out of scope here):
12
+ *
13
+ * 1. Files whose language uses brace-based block structure (the C family:
14
+ * ts/js/tsx/jsx/rs/go/java/kt/cs/c/cpp/h/hpp/php/swift/scala/dart/…)
15
+ * are split at TOP-LEVEL function/method/class boundaries. A brace-
16
+ * counting scanner finds the column-0 (or nearly top-level) brace ranges
17
+ * that begin with a function/class signature, and each such range becomes
18
+ * a chunk. The scanner is indentation-aware: a `{` at the top level of a
19
+ * block-structure file (column 0, or preceded only by whitespace at a
20
+ * low indentation that matches a declaration) opens a boundary.
21
+ *
22
+ * 2. Python (significant-whitespace): top-level `def`/`class` lines open
23
+ * boundaries; the chunk extends to the next top-level declaration.
24
+ *
25
+ * 3. Everything else (markdown, json, yaml, sh, txt, files with no clear
26
+ * boundaries, files too big for the boundary scanner) falls back to
27
+ * fixed-size line windows with a small overlap.
28
+ *
29
+ * Limitations (documented, accepted):
30
+ * - Regex/indentation heuristics can mis-split unusual code (a `{` on its
31
+ * own line at column 0 that is actually a continuation, C++ templates,
32
+ * nested namespaces, decorators, multiline signatures). A mis-split
33
+ * produces a chunk that is still a valid file:line citation, just with
34
+ * slightly less ideal boundaries — the semantic search stays usable.
35
+ * - Very large single files (e.g. a 5k-line bundle) are chunked by line
36
+ * windows, not by function — acceptable for this project's scale.
37
+ */
38
+
39
+ import * as fs from "node:fs"
40
+ import * as path from "node:path"
41
+
42
+ import { MAX_CHUNK_CHARS } from "./embedder.js"
43
+
44
+ /** A chunk of a file with its line citation. */
45
+ export interface Chunk {
46
+ /** Workspace-relative POSIX path. */
47
+ file: string
48
+ /** 1-based inclusive start line. */
49
+ startLine: number
50
+ /** 1-based inclusive end line. */
51
+ endLine: number
52
+ /** Chunk text (the exact lines startLine..endLine). */
53
+ content: string
54
+ }
55
+
56
+ /** Max lines per fallback window. */
57
+ export const FALLBACK_WINDOW_LINES = 60
58
+ /** Overlap lines between fallback windows (keeps boundaries fuzzy-joined). */
59
+ export const FALLBACK_OVERLAP_LINES = 10
60
+ /** Files larger than this (lines) use fallback chunking even if they have boundaries. */
61
+ export const MAX_BOUNDARY_SCAN_LINES = 3000
62
+
63
+ /** `{`-using languages whose top-level declarations we can find with brace counting. */
64
+ const BRACE_LANGS = new Set([
65
+ ".ts",
66
+ ".tsx",
67
+ ".js",
68
+ ".jsx",
69
+ ".mjs",
70
+ ".cjs",
71
+ ".rs",
72
+ ".go",
73
+ ".java",
74
+ ".kt",
75
+ ".kts",
76
+ ".cs",
77
+ ".c",
78
+ ".h",
79
+ ".cpp",
80
+ ".hpp",
81
+ ".php",
82
+ ".swift",
83
+ ".scala",
84
+ ".dart",
85
+ ".css",
86
+ ])
87
+
88
+ /** Python / significant-whitespace languages chunked by top-level def/class. */
89
+ const INDENT_LANGS = new Set([".py", ".rb", ".ex", ".exs", ".el", ".sh", ".bash", ".zsh"])
90
+
91
+ /** Lines that look like a C-family declaration (function/method/class). */
92
+ const DECL_RE =
93
+ /^(export\s+)?(abstract\s+|async\s+|static\s+|public\s+|private\s+|protected\s+|internal\s+|final\s+|override\s+|sealed\s+)*\s*(function|def|class|interface|struct|enum|trait|impl|namespace|module|type|fn)\b/
94
+
95
+ /** A `{`-using file's top-level brace ranges, in line order. */
96
+ function findTopLevelBraceRanges(lines: string[]): Array<{ start: number; end: number }> {
97
+ const ranges: Array<{ start: number; end: number }> = []
98
+ let depth = 0
99
+ let rangeStart: number | undefined
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const line = lines[i]
103
+ for (const ch of line) {
104
+ if (ch === "{") {
105
+ if (depth === 0) {
106
+ rangeStart = i
107
+ }
108
+ depth++
109
+ } else if (ch === "}") {
110
+ depth--
111
+ if (depth === 0 && rangeStart !== undefined) {
112
+ ranges.push({ start: rangeStart, end: i })
113
+ rangeStart = undefined
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return ranges
119
+ }
120
+
121
+ /** True when the line starts a top-level declaration (column-0-ish keyword). */
122
+ function isTopLevelDeclaration(line: string): boolean {
123
+ const trimmed = line.trim()
124
+ if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
125
+ return false
126
+ }
127
+ // Column 0 check: the declaration must start at the very left edge (or a
128
+ // leading tab that's effectively top level). This avoids matching
129
+ // declarations nested inside methods.
130
+ const indent = line.match(/^(\s*)/)?.[1] ?? ""
131
+ if (indent.length > 0 && !indent.startsWith("\t")) {
132
+ // Indented with spaces → nested (Python-style or inside a block) → not top level.
133
+ return false
134
+ }
135
+ return DECL_RE.test(trimmed)
136
+ }
137
+
138
+ /** Chunk a brace-based file by top-level declaration ranges. */
139
+ function chunkBraceFile(lines: string[], file: string): Chunk[] {
140
+ const ranges = findTopLevelBraceRanges(lines)
141
+ if (ranges.length === 0) {
142
+ return []
143
+ }
144
+ const chunks: Chunk[] = []
145
+ let cursor = 0
146
+ for (const range of ranges) {
147
+ const start = range.start
148
+ const end = range.end
149
+ // Lines between the previous range and this one (head imports,
150
+ // module-level constants, standalone statements like `const helper = …`
151
+ // that carry no braces) must not vanish from the index — emit them as
152
+ // their own chunk when they contain anything non-blank. Blank-only
153
+ // gaps are not worth a citation.
154
+ if (cursor < start) {
155
+ const gap = lines.slice(cursor, start)
156
+ if (gap.some((l) => l.trim() !== "")) {
157
+ chunks.push({
158
+ file,
159
+ startLine: cursor + 1,
160
+ endLine: start,
161
+ content: gap.join("\n"),
162
+ })
163
+ }
164
+ }
165
+ // The brace range itself (function/class/struct body). A tiny stub
166
+ // (e.g. a one-line `{ }`) is left to the next gap slice instead of
167
+ // getting its own chunk.
168
+ if (end - start >= 2) {
169
+ chunks.push({
170
+ file,
171
+ startLine: start + 1,
172
+ endLine: end + 1,
173
+ content: lines.slice(start, end + 1).join("\n"),
174
+ })
175
+ cursor = end + 1
176
+ }
177
+ }
178
+ // Any leftover lines after the last range (file tail) become a small chunk
179
+ // (skipped when it would be an empty/whitespace-only stub — the trailing
180
+ // blank line after the final `}` is not worth a citation).
181
+ if (cursor < lines.length) {
182
+ const tail = lines.slice(cursor)
183
+ if (tail.some((l) => l.trim() !== "")) {
184
+ chunks.push({
185
+ file,
186
+ startLine: cursor + 1,
187
+ endLine: lines.length,
188
+ content: tail.join("\n"),
189
+ })
190
+ }
191
+ }
192
+ return chunks
193
+ }
194
+
195
+ /** Chunk a Python-style file by top-level def/class/block boundaries. */
196
+ function chunkIndentFile(lines: string[], file: string): Chunk[] {
197
+ const chunks: Chunk[] = []
198
+ let start = 0
199
+ for (let i = 0; i < lines.length; i++) {
200
+ const trimmed = lines[i].trim()
201
+ // A top-level (column 0) def/class/import/if/for/while opens a new chunk.
202
+ if (/^(def|class|async\s+def|import|from|if|for|while|with|try|except|@)\b/.test(trimmed) && !lines[i].startsWith(" ")) {
203
+ if (i > start) {
204
+ chunks.push({ file, startLine: start + 1, endLine: i, content: lines.slice(start, i).join("\n") })
205
+ }
206
+ start = i
207
+ }
208
+ }
209
+ if (start < lines.length) {
210
+ chunks.push({ file, startLine: start + 1, endLine: lines.length, content: lines.slice(start).join("\n") })
211
+ }
212
+ return chunks
213
+ }
214
+
215
+ /** Fallback: fixed-size line windows with overlap. */
216
+ function chunkFallback(lines: string[], file: string): Chunk[] {
217
+ const chunks: Chunk[] = []
218
+ const windowSize = FALLBACK_WINDOW_LINES
219
+ const overlap = FALLBACK_OVERLAP_LINES
220
+ if (lines.length <= windowSize) {
221
+ chunks.push({ file, startLine: 1, endLine: lines.length, content: lines.join("\n") })
222
+ return chunks
223
+ }
224
+ let start = 0
225
+ while (start < lines.length) {
226
+ const end = Math.min(lines.length, start + windowSize)
227
+ chunks.push({ file, startLine: start + 1, endLine: end, content: lines.slice(start, end).join("\n") })
228
+ if (end >= lines.length) {
229
+ break
230
+ }
231
+ start = end - overlap
232
+ }
233
+ return chunks
234
+ }
235
+
236
+ /**
237
+ * Split one oversized chunk into pieces of at most MAX_CHUNK_CHARS (the cap
238
+ * lives in embedder.ts — the OpenRouter embeddings endpoint rejects any single
239
+ * input over the model's limit, seen live as an HTTP 422 on an 8.8M-char
240
+ * minified/data chunk and an HTTP 400 at ~100k chars on qwen/qwen3-embedding-4b
241
+ * whose context is 40960 tokens). Line-based windowing alone cannot guarantee
242
+ * the cap (a file can be a single giant line), so every chunk is split down to
243
+ * at most MAX_CHUNK_CHARS, preferring line boundaries and hard-slicing
244
+ * mid-line only for unbroken giant lines.
245
+ */
246
+ function splitOversizedChunk(chunk: Chunk): Chunk[] {
247
+ if (chunk.content.length <= MAX_CHUNK_CHARS) {
248
+ return [chunk]
249
+ }
250
+ const out: Chunk[] = []
251
+ let cursor = 0
252
+ let startLine = chunk.startLine
253
+ while (cursor < chunk.content.length) {
254
+ let end = Math.min(cursor + MAX_CHUNK_CHARS, chunk.content.length)
255
+ // Back off to the previous newline (consuming it) unless this is the
256
+ // final slice or the window is one unbroken line.
257
+ if (end < chunk.content.length) {
258
+ const nl = chunk.content.lastIndexOf("\n", end - 1)
259
+ if (nl > cursor) {
260
+ end = nl + 1
261
+ }
262
+ }
263
+ const piece = chunk.content.slice(cursor, end)
264
+ const newlineCount = (piece.match(/\n/g) ?? []).length
265
+ // Line bookkeeping: a trailing \n terminates the final line of the
266
+ // piece, so it covers newlineCount lines; otherwise it covers
267
+ // newlineCount + 1. A piece with no \n at all is a mid-line hard slice
268
+ // of one giant line, so it cites the same line and the next slice
269
+ // starts on the same line number.
270
+ const coversLines = newlineCount + (piece.endsWith("\n") ? 0 : 1)
271
+ out.push({
272
+ file: chunk.file,
273
+ startLine,
274
+ endLine: startLine + coversLines - 1,
275
+ content: piece,
276
+ })
277
+ if (newlineCount > 0) {
278
+ startLine += coversLines
279
+ }
280
+ cursor = end
281
+ }
282
+ return out
283
+ }
284
+
285
+ /**
286
+ * Split a source file into line-cited chunks.
287
+ *
288
+ * @param absPath absolute path of the file to chunk
289
+ * @param relPath workspace-relative POSIX path (used for citations)
290
+ */
291
+ function chunkFileUncapped(absPath: string, relPath: string): Chunk[] {
292
+ let content: string
293
+ try {
294
+ content = fs.readFileSync(absPath, "utf-8")
295
+ } catch {
296
+ return []
297
+ }
298
+ const lines = content.split(/\r?\n/)
299
+ if (lines.length === 0 || (lines.length === 1 && lines[0].trim() === "")) {
300
+ return []
301
+ }
302
+ const ext = path.extname(relPath).toLowerCase()
303
+
304
+ // Very large files fall back to windows — the boundary scanner is O(n)
305
+ // brace counting but a 10k-line minified bundle isn't a boundary case worth
306
+ // scanning, and windowing is what the reference does for huge files too.
307
+ if (lines.length > MAX_BOUNDARY_SCAN_LINES) {
308
+ return chunkFallback(lines, relPath)
309
+ }
310
+
311
+ if (BRACE_LANGS.has(ext)) {
312
+ const chunks = chunkBraceFile(lines, relPath)
313
+ if (chunks.length > 0) {
314
+ return chunks
315
+ }
316
+ return chunkFallback(lines, relPath)
317
+ }
318
+
319
+ if (INDENT_LANGS.has(ext)) {
320
+ const chunks = chunkIndentFile(lines, relPath)
321
+ if (chunks.length > 0) {
322
+ return chunks
323
+ }
324
+ return chunkFallback(lines, relPath)
325
+ }
326
+
327
+ return chunkFallback(lines, relPath)
328
+ }
329
+
330
+ /**
331
+ * Split a source file into line-cited chunks, each capped at MAX_CHUNK_CHARS
332
+ * characters so no chunk can exceed the embedding endpoint's input limit.
333
+ *
334
+ * @param absPath absolute path of the file to chunk
335
+ * @param relPath workspace-relative POSIX path (used for citations)
336
+ */
337
+ export function chunkFile(absPath: string, relPath: string): Chunk[] {
338
+ return chunkFileUncapped(absPath, relPath).flatMap(splitOversizedChunk)
339
+ }