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,223 @@
1
+ /**
2
+ * `headlesscode index` subcommand — build/refresh the codebase-search index.
3
+ *
4
+ * npx tsx src/cli.ts index --workspace <path> [--model <id>] [--embedding-backend <openrouter|ollama|airunner>]
5
+ *
6
+ * Walks the workspace's real source files (git-aware, .gitignore-respecting),
7
+ * chunks them, embeds ONLY the chunks whose content hash changed since the
8
+ * last index (incremental — the cost-control mechanism), and writes the index
9
+ * into the CENTRAL per-project data store
10
+ * (~/.local/share/headlesscode/projects/<key>/codesearch/index.jsonl — see
11
+ * src/project-store.ts). Worktrees of a repo share that store automatically.
12
+ *
13
+ * Three embedding backends are supported:
14
+ * - openrouter (default) — cloud, `qwen/qwen3-embedding-4b`, costs real
15
+ * money (priced in src/budget/cost.ts);
16
+ * - ollama (opt-in) — local `qwen3-embedding:8b` via
17
+ * HEADLESSCODE_OLLAMA_URL (default http://localhost:11434). Free, no
18
+ * cost tracking; produces 4096-dim vectors, NOT interchangeable with the
19
+ * cloud model's 2560-dim vectors (a backend switch requires a full
20
+ * reindex — the build handles it automatically by treating every stored
21
+ * vector from the other backend as stale);
22
+ * - airunner (opt-in) — local intfloat/e5-large via the AIRunner server's
23
+ * native embedding endpoint (HEADLESSCODE_AIRUNNER_EMBED_URL, default
24
+ * http://localhost:8080). Free, no cost tracking; produces 1024-dim
25
+ * vectors, again NOT interchangeable with the other backends' vectors.
26
+ *
27
+ * Indexing is a SEPARATE, EXPLICIT step (it costs real money and takes real
28
+ * time) — it is never auto-triggered mid-session. Sessions consume whatever
29
+ * index already exists or tell the model to ask a human to build one.
30
+ */
31
+
32
+ import * as path from "node:path"
33
+
34
+ import {
35
+ createEmbedder,
36
+ EMBEDDING_BACKEND_ENV,
37
+ OPENROUTER_EMBEDDING_MODEL_ENV,
38
+ resolveEmbeddingBackend,
39
+ resolveEmbeddingModel,
40
+ } from "./embedder.js"
41
+ import { buildIndex, indexFilePath } from "./index.js"
42
+ import { estimateCost, priceFor } from "../budget/cost.js"
43
+ import { DEFAULT_AIRUNNER_EMBEDDING_MODEL, AIRUNNER_EMBEDDING_MODEL_ENV } from "./airunner-embedder.js"
44
+ import { DEFAULT_OLLAMA_EMBEDDING_MODEL, OLLAMA_EMBEDDING_MODEL_ENV } from "./ollama-embedder.js"
45
+
46
+ const INDEX_USAGE = `headlesscode index — build/refresh the codebase semantic-search index
47
+
48
+ Usage:
49
+ headlesscode index --workspace <path> [--model <id>] [--embedding-backend <openrouter|ollama|airunner>]
50
+
51
+ Options:
52
+ --workspace <path> Workspace root to index (required)
53
+ --model <id> OpenRouter embedding model id (default:
54
+ $OPENROUTER_EMBEDDING_MODEL or ${resolveEmbeddingModel()})
55
+ --embedding-backend <b> Embedding backend: openrouter (default), ollama
56
+ (local, opt-in), or airunner (local AIRunner
57
+ server, opt-in). Default: $HEADLESSCODE_EMBEDDING_BACKEND
58
+ or openrouter
59
+ --help Show this help and exit
60
+
61
+ Environment:
62
+ HEADLESSCODE_EMBEDDING_BACKEND openrouter | ollama | airunner (default openrouter)
63
+ HEADLESSCODE_EMBEDDING_MODEL OpenRouter embedding model override
64
+ HEADLESSCODE_OPENROUTER_EMBEDDING_MODEL OpenRouter embedding model override
65
+ (preferred for clarity)
66
+ HEADLESSCODE_OLLAMA_URL Ollama server URL (default http://localhost:11434)
67
+ HEADLESSCODE_OLLAMA_EMBEDDING_MODEL Ollama embedding model (default ${DEFAULT_OLLAMA_EMBEDDING_MODEL})
68
+ HEADLESSCODE_AIRUNNER_EMBED_URL AIRunner server URL (default http://localhost:8080)
69
+ HEADLESSCODE_AIRUNNER_EMBED_MODEL AIRunner embedding model (default ${DEFAULT_AIRUNNER_EMBEDDING_MODEL})
70
+
71
+ This builds (or incrementally refreshes) the codebase-search index used by the
72
+ codebase_search tool. The index is stored in the CENTRAL per-project data store
73
+ (~/.local/share/headlesscode/projects/<key>/codesearch/index.jsonl) — keyed by
74
+ the repo's git-common-dir, so every worktree of a repo shares one index with
75
+ zero per-directory setup. Chunks whose content hash is unchanged since the last
76
+ index are skipped without re-embedding, so re-running after a small change only
77
+ pays for the changed chunks.
78
+ `
79
+
80
+ interface IndexCliOptions {
81
+ workspace?: string
82
+ model?: string
83
+ embeddingBackend?: string
84
+ help: boolean
85
+ }
86
+
87
+ export function parseIndexArgs(argv: string[]): { options: IndexCliOptions; error?: string } {
88
+ const options: IndexCliOptions = { help: false }
89
+ for (let i = 0; i < argv.length; i++) {
90
+ const arg = argv[i]
91
+ const eq = arg.indexOf("=")
92
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
93
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
94
+ const next = (): string | undefined => {
95
+ if (inlineValue !== undefined) {
96
+ return inlineValue
97
+ }
98
+ const v = argv[i + 1]
99
+ if (v === undefined || v.startsWith("--")) {
100
+ return undefined
101
+ }
102
+ i++
103
+ return v
104
+ }
105
+ switch (flag) {
106
+ case "--workspace":
107
+ case "--model":
108
+ case "--embedding-backend": {
109
+ const value = next()
110
+ if (value === undefined) {
111
+ return { options, error: `Missing value for ${flag}` }
112
+ }
113
+ if (flag === "--workspace") {
114
+ options.workspace = value
115
+ } else if (flag === "--model") {
116
+ options.model = value
117
+ } else {
118
+ options.embeddingBackend = value
119
+ }
120
+ break
121
+ }
122
+ case "--help":
123
+ case "-h":
124
+ options.help = true
125
+ break
126
+ default:
127
+ return { options, error: `Unknown argument: ${arg}` }
128
+ }
129
+ }
130
+ return { options }
131
+ }
132
+
133
+ export async function indexMain(argv: string[]): Promise<number> {
134
+ const { options, error } = parseIndexArgs(argv)
135
+ if (error) {
136
+ process.stderr.write(`headlesscode index: ${error}\n\n${INDEX_USAGE}`)
137
+ return 2
138
+ }
139
+ if (options.help) {
140
+ process.stdout.write(INDEX_USAGE)
141
+ return 0
142
+ }
143
+ if (!options.workspace) {
144
+ process.stderr.write(`headlesscode index: --workspace <path> is required\n\n${INDEX_USAGE}`)
145
+ return 2
146
+ }
147
+
148
+ const workspaceRoot = path.resolve(options.workspace)
149
+ const backend = resolveEmbeddingBackend(process.env, options.embeddingBackend)
150
+
151
+ const embedder = createEmbedder(backend, { model: options.model }, process.env)
152
+ const modelForEnv = options.model?.trim() || process.env[OPENROUTER_EMBEDDING_MODEL_ENV]?.trim()
153
+
154
+ if (backend === "openrouter" && !process.env.HEADLESSCODE_OPENROUTER_API_KEY) {
155
+ process.stderr.write("headlesscode index: HEADLESSCODE_OPENROUTER_API_KEY is not set (required for cloud embedding).\n")
156
+ return 2
157
+ }
158
+
159
+ process.stdout.write(`Indexing workspace: ${workspaceRoot}\n`)
160
+ process.stdout.write(`Embedding backend: ${backend}\n`)
161
+ process.stdout.write(`Embedding model: ${embedder.model}\n`)
162
+ if (backend === "ollama") {
163
+ const ollamaModel = process.env[OLLAMA_EMBEDDING_MODEL_ENV]?.trim() || DEFAULT_OLLAMA_EMBEDDING_MODEL
164
+ process.stdout.write(` (local, no embedding cost — model: ${ollamaModel})\n`)
165
+ }
166
+ if (backend === "airunner") {
167
+ const airunnerModel = process.env[AIRUNNER_EMBEDDING_MODEL_ENV]?.trim() || DEFAULT_AIRUNNER_EMBEDDING_MODEL
168
+ process.stdout.write(` (local AIRunner server, no embedding cost — model: ${airunnerModel})\n`)
169
+ }
170
+
171
+ try {
172
+ const result = await buildIndex(
173
+ embedder,
174
+ workspaceRoot,
175
+ (scanned, total) => {
176
+ process.stdout.write(`\r scanned ${scanned}/${total} files`)
177
+ },
178
+ backend,
179
+ (embedded, total) => {
180
+ process.stdout.write(`\r embedded ${embedded}/${total} chunks`)
181
+ },
182
+ )
183
+ if (result.filesScanned > 0) {
184
+ process.stdout.write(`\r scanned ${result.filesScanned}/${result.filesScanned} files\n`)
185
+ } else {
186
+ process.stdout.write("\n")
187
+ }
188
+ // The embedding phase used to run fully silent (a large repo looked
189
+ // "stalled" between the scan line and the summary — live report
190
+ // 2026-08-16). Keep the final state of the per-batch progress visible.
191
+ process.stdout.write(` embedded ${result.chunksEmbedded}/${result.chunksEmbedded} chunks\n`)
192
+
193
+ const indexFile = indexFilePath(workspaceRoot)
194
+ const costLine =
195
+ backend === "openrouter"
196
+ ? (() => {
197
+ const price = priceFor(result.model)
198
+ const estCostUsd = estimateCost({
199
+ model: result.model,
200
+ inputTokens: result.promptTokens,
201
+ outputTokens: 0,
202
+ })
203
+ return ` estimated cost: $${estCostUsd.toFixed(6)} (${result.model} @ $${price.input}/1M input)\n`
204
+ })()
205
+ : ` estimated cost: $0.00 (local backend — no cloud spend)\n`
206
+ process.stdout.write(
207
+ `Index written: ${indexFile}\n` +
208
+ ` files scanned: ${result.filesScanned}\n` +
209
+ ` chunks embedded: ${result.chunksEmbedded}\n` +
210
+ ` chunks skipped (unchanged): ${result.chunksSkipped}\n` +
211
+ ` chunks removed: ${result.chunksRemoved}\n` +
212
+ ` total chunks: ${result.totalChunks}\n` +
213
+ ` embedding tokens: ${result.promptTokens}\n` +
214
+ costLine,
215
+ )
216
+ return 0
217
+ } catch (err) {
218
+ process.stderr.write(
219
+ `headlesscode index: ${err instanceof Error ? err.message : String(err)}\n`,
220
+ )
221
+ return 1
222
+ }
223
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Cloud (OpenRouter) embedder for the codebase index.
3
+ *
4
+ * SEPARATE from the Phase 3 memory embedder (src/memory/embed.ts,
5
+ * `createLocalEmbedder`) — that one is a deliberately cheap deterministic
6
+ * hash stand-in for handful-of-records recall. This one embeds whole-repo
7
+ * source chunks with a real cloud model and reports real token usage into the
8
+ * budget (src/budget/cost.ts).
9
+ *
10
+ * ─── OpenRouter embeddings — verified findings (2026-08-01) ─────────────────
11
+ *
12
+ * This section is load-bearing: the project owner needs to know exactly what
13
+ * was verified before building on it, because OpenRouter's embeddings story
14
+ * is (as of this date) NOT what a reader might assume.
15
+ *
16
+ * 1. MODEL AVAILABILITY: Embedding models are NOT listed in OpenRouter's
17
+ * public `/api/v1/models` catalog (337 models returned, zero embedding
18
+ * ids, zero embedding-modality entries — verified live against a real
19
+ * API key on 2026-08-01). They are also absent from `/api/v1/models?q=`
20
+ * and from the single-model `/api/v1/models/:id` endpoint (404). The
21
+ * embeddings endpoint itself is LIVE, however: POST /api/v1/embeddings
22
+ * returns proper 4xx errors for bad models, and real embeddings for the
23
+ * few ids the platform currently serves.
24
+ * 2. MODELS THAT ACTUALLY WORK (probed live, real API key, 2026-08-01):
25
+ * - `google/gemini-embedding-001` → 3072 dims, works.
26
+ * - `qwen/qwen3-embedding-4b` → 2560 dims, works (Qwen3-Embedding-4B).
27
+ * - `qwen/qwen3-embedding-8b` → engine overloaded at probe time (the
28
+ * model exists; the endpoint was just busy).
29
+ * Models that FAILED live: `qwen/qwen3-embedding-0.6b` (no endpoints),
30
+ * `openai/text-embedding-3-small`/`-large` (guardrail/data-policy 404),
31
+ * `google/gemini-embedding-002` / `google/text-embedding-004` (do not
32
+ * exist), `mistralai/mistral-embed-2312` / `mistralai/codestral-embed-
33
+ * 2505` (no allowed providers on this account), `google/gemini-
34
+ * embedding-001:free` (no free endpoints).
35
+ * 3. PRICING: OpenRouter's response `usage` object includes `cost` (USD) and
36
+ * `cost_details`, but NOT the model's price-per-token. `qwen/qwen3-
37
+ * embedding-4b` responded with `cost: 0` and `is_byok: true` (a BYOK
38
+ * routed endpoint — the account's own provider key, costed at $0 through
39
+ * OpenRouter). `google/gemini-embedding-001` responded with real cost
40
+ * (~$0.00000015/token → $0.15/1M). Because embedding models are hidden
41
+ * from the catalog, their published OpenRouter list prices could NOT be
42
+ * read from the API on this date, and the doc page is a client-rendered
43
+ * app (no server-rendered content to scrape). We therefore price
44
+ * `qwen/qwen3-embedding-4b` conservatively at the same per-token rate as
45
+ * Google's embedding (input-only, $0.15/1M — see src/budget/cost.ts) and
46
+ * treat OpenRouter's own `usage.cost` as advisory only.
47
+ * 4. BATCHING: confirmed live — N inputs in one request → N embeddings, one
48
+ * HTTP call, `usage.prompt_tokens` summed across all inputs. Embedding an
49
+ * entire repo's changed chunks is therefore ONE request per model-context
50
+ * budget, not one request per chunk.
51
+ * 5. DIMENSIONS: `qwen/qwen3-embedding-4b` = 2560, `google/gemini-
52
+ * embedding-001` = 3072. Vectors are stored as plain JSON number arrays in
53
+ * the index; dimension is whatever the model returns, and cosine similarity
54
+ * is computed over whatever length comes back, so no fixed-dimension
55
+ * constraint needs to be hard-coded.
56
+ *
57
+ * The chosen default model is `qwen/qwen3-embedding-8b` (the 4b→8b switch is
58
+ * documented below; 2560+ dims — a solid quality/size/cost balance for code
59
+ * search), pinned to the DeepInfra provider (see DEFAULT_EMBEDDING_PROVIDER).
60
+ * Both the model and the pin are overridable per machine via the central
61
+ * store's settings.json (src/project-store.ts) and per build via
62
+ * `HEADLESSCODE_EMBEDDING_MODEL` (or the constructor option).
63
+ */
64
+
65
+ import { OpenRouterClient } from "../llm/openrouter.js"
66
+ import { loadCentralSettings, type CentralSettings } from "../project-store.js"
67
+ import { AirunnerEmbedder, resolveAirunnerEmbeddingModel, resolveAirunnerUrl } from "./airunner-embedder.js"
68
+ import {
69
+ DEFAULT_OLLAMA_EMBEDDING_MODEL,
70
+ DEFAULT_OLLAMA_URL,
71
+ OLLAMA_EMBEDDING_MODEL_ENV,
72
+ OLLAMA_URL_ENV,
73
+ OllamaEmbedder,
74
+ resolveOllamaEmbeddingModel,
75
+ resolveOllamaUrl,
76
+ } from "./ollama-embedder.js"
77
+
78
+ /**
79
+ * Default embedding model (see header comment for verification notes).
80
+ * The 8b variant (not 4b) — matching airunner's own working setup
81
+ * (projects/uwuchat/server/embedding_provider.py): chosen there for zero data
82
+ * retention + lower cost. The default is overridable per machine via the
83
+ * central store's settings.json (`embedding.model` — see src/project-store.ts)
84
+ * and per build via $HEADLESSCODE_EMBEDDING_MODEL / --model.
85
+ */
86
+ export const DEFAULT_EMBEDDING_MODEL = "qwen/qwen3-embedding-8b"
87
+
88
+ /** Env var that overrides the embedding model. */
89
+ export const EMBEDDING_MODEL_ENV = "HEADLESSCODE_EMBEDDING_MODEL"
90
+
91
+ /** Embedding backends (the codebase index build + query can pick between). */
92
+ export const EMBEDDING_BACKENDS = ["openrouter", "ollama", "airunner"] as const
93
+
94
+ /** Default backend — OpenRouter, matching the pre-existing behavior. */
95
+ export const DEFAULT_EMBEDDING_BACKEND = "openrouter"
96
+
97
+ /** Env var that selects the backend (`openrouter` | `ollama` | `airunner`). */
98
+ export const EMBEDDING_BACKEND_ENV = "HEADLESSCODE_EMBEDDING_BACKEND"
99
+
100
+ /** Env var that overrides the OpenRouter embedding model. */
101
+ export const OPENROUTER_EMBEDDING_MODEL_ENV = "HEADLESSCODE_OPENROUTER_EMBEDDING_MODEL"
102
+
103
+ /**
104
+ * Default OpenRouter provider pin for embedding requests, matching airunner's
105
+ * working setup (projects/uwuchat/server/embedding_provider.py): DeepInfra,
106
+ * zero data retention, no fallbacks. Verified live 2026-08-04 against
107
+ * OpenRouter's `/api/v1/models/<id>/endpoints` that the provider slug casing
108
+ * is exactly `"DeepInfra"` (capital D/I). Overridable per machine via the
109
+ * central store's settings.json (`embedding.provider` /
110
+ * `embedding.allowFallbacks` — see src/project-store.ts).
111
+ */
112
+ export const DEFAULT_EMBEDDING_PROVIDER = "DeepInfra"
113
+ export const DEFAULT_EMBEDDING_ALLOW_FALLBACKS = false
114
+
115
+ /** The backend an index was built with (recorded per entry + in metadata). */
116
+ export type EmbeddingBackend = (typeof EMBEDDING_BACKENDS)[number]
117
+
118
+ /**
119
+ * Resolve the embedding backend: explicit override → env → default. The
120
+ * default is deliberately `openrouter` (opt-in for Ollama), because hosted/
121
+ * headless workers have no local GPU — see plans/local-embeddings-ollama.md.
122
+ */
123
+ export function resolveEmbeddingBackend(env: NodeJS.ProcessEnv = process.env, override?: string): EmbeddingBackend {
124
+ const raw = (override?.trim() || env[EMBEDDING_BACKEND_ENV]?.trim() || DEFAULT_EMBEDDING_BACKEND).toLowerCase()
125
+ if (raw === "ollama" || raw === "openrouter" || raw === "airunner") {
126
+ return raw
127
+ }
128
+ throw new Error(
129
+ `Invalid embedding backend "${raw}" (from ${EMBEDDING_BACKEND_ENV} or --embedding-backend): expected "openrouter", "ollama", or "airunner"`,
130
+ )
131
+ }
132
+
133
+ /** Embedding batch size (chunks per request). Kept modest for retry granularity. */
134
+ export const EMBED_BATCH_SIZE = 128
135
+
136
+ /** Max tokens per chunk before we refuse to embed it (4 chars/token heuristic). */
137
+ export const MAX_CHUNK_CHARS = 8_000
138
+
139
+ /** How many consecutive 429/overloaded responses before giving up. */
140
+ const MAX_RETRIES = 3
141
+ const INITIAL_RETRY_DELAY_MS = 500
142
+
143
+ /**
144
+ * Resolve the embedding model: constructor option → env → central settings →
145
+ * built-in default. The central settings default (src/project-store.ts's
146
+ * settings.json `embedding.model`) is the per-machine override knob so the
147
+ * 8b default is not hardcoded in application code with no way to change it.
148
+ */
149
+ export function resolveEmbeddingModel(env: NodeJS.ProcessEnv = process.env, override?: string): string {
150
+ const settings = loadCentralSettings()
151
+ return (
152
+ override?.trim() ||
153
+ env[EMBEDDING_MODEL_ENV]?.trim() ||
154
+ settings.embedding?.model?.trim() ||
155
+ DEFAULT_EMBEDDING_MODEL
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Resolve the OpenRouter provider pin for embedding requests: central settings
161
+ * → built-in default. Returns undefined only when settings explicitly disable
162
+ * pinning (allowFallbacks without a provider is treated as no pin).
163
+ */
164
+ export function resolveEmbeddingProvider(settings: CentralSettings = loadCentralSettings()): {
165
+ order?: string[]
166
+ allowFallbacks?: boolean
167
+ } | undefined {
168
+ const provider = settings.embedding?.provider?.trim()
169
+ if (!provider) {
170
+ return { order: [DEFAULT_EMBEDDING_PROVIDER], allowFallbacks: DEFAULT_EMBEDDING_ALLOW_FALLBACKS }
171
+ }
172
+ return {
173
+ order: [provider],
174
+ ...(typeof settings.embedding?.allowFallbacks === "boolean"
175
+ ? { allowFallbacks: settings.embedding.allowFallbacks }
176
+ : { allowFallbacks: DEFAULT_EMBEDDING_ALLOW_FALLBACKS }),
177
+ }
178
+ }
179
+
180
+ /** The embedder interface the index build + tool handler depend on. */
181
+ export interface Embedder {
182
+ /** Model id used for embedding (for pricing/usage accounting). */
183
+ readonly model: string
184
+ /**
185
+ * Embed a batch of text chunks. Returns one vector per input, in order,
186
+ * plus the real token usage (for budget accounting).
187
+ */
188
+ embedBatch(texts: string[]): Promise<{ embeddings: number[][]; promptTokens: number; totalTokens: number }>
189
+ }
190
+
191
+ /** Result of one embedder batch, including model + cost info for accounting. */
192
+ export interface EmbedResult {
193
+ embeddings: number[][]
194
+ model: string
195
+ promptTokens: number
196
+ totalTokens: number
197
+ }
198
+
199
+ /** Name of the backend field on the index metadata block (shared with index.ts/types.ts). */
200
+ export const METADATA_BACKEND_KEY = "backend" as const
201
+
202
+ /** Name of the model field on the index metadata block (shared with index.ts/types.ts). */
203
+ export const METADATA_MODEL_KEY = "model" as const
204
+
205
+ /**
206
+ * Create the embedder for a backend, defaulting to `openrouter`. The
207
+ * OpenRouter model resolves from the constructor option → env → default; the
208
+ * Ollama model/URL from their own env vars (or constructor options).
209
+ */
210
+ export function createEmbedder(
211
+ backend?: EmbeddingBackend,
212
+ options: {
213
+ /** OpenRouter embedding model override (--model / HEADLESSCODE_OPENROUTER_EMBEDDING_MODEL). */
214
+ model?: string
215
+ /** Ollama embedding model override (HEADLESSCODE_OLLAMA_EMBEDDING_MODEL). */
216
+ ollamaModel?: string
217
+ /** Ollama server URL override (HEADLESSCODE_OLLAMA_URL). */
218
+ ollamaUrl?: string
219
+ /** AIRunner embedding model override (HEADLESSCODE_AIRUNNER_EMBED_MODEL). */
220
+ airunnerModel?: string
221
+ /** AIRunner server URL override (HEADLESSCODE_AIRUNNER_EMBED_URL). */
222
+ airunnerUrl?: string
223
+ } = {},
224
+ env: NodeJS.ProcessEnv = process.env,
225
+ ): Embedder {
226
+ const resolved = resolveEmbeddingBackend(env, backend)
227
+ if (resolved === "ollama") {
228
+ return new OllamaEmbedder(options.ollamaModel ?? resolveOllamaEmbeddingModel(env), options.ollamaUrl ?? resolveOllamaUrl(env))
229
+ }
230
+ if (resolved === "airunner") {
231
+ return new AirunnerEmbedder(
232
+ options.airunnerModel ?? resolveAirunnerEmbeddingModel(env),
233
+ options.airunnerUrl ?? resolveAirunnerUrl(env),
234
+ )
235
+ }
236
+ return new OpenRouterEmbedder(
237
+ options.model ?? env[OPENROUTER_EMBEDDING_MODEL_ENV] ?? resolveEmbeddingModel(env),
238
+ undefined,
239
+ resolveEmbeddingProvider(),
240
+ )
241
+ }
242
+
243
+ /**
244
+ * OpenRouter-backed embedder. Batches chunks (one HTTP call per batch), retries
245
+ * transient failures with backoff, and returns real usage for budget feeding.
246
+ */
247
+ export class OpenRouterEmbedder implements Embedder {
248
+ private readonly client: OpenRouterClient
249
+ private readonly provider: { order?: string[]; allowFallbacks?: boolean } | undefined
250
+
251
+ constructor(
252
+ readonly model: string = resolveEmbeddingModel(),
253
+ client?: OpenRouterClient,
254
+ provider: { order?: string[]; allowFallbacks?: boolean } | undefined = resolveEmbeddingProvider(),
255
+ ) {
256
+ this.client = client ?? new OpenRouterClient()
257
+ this.provider = provider
258
+ }
259
+
260
+ async embedBatch(texts: string[]): Promise<EmbedResult> {
261
+ if (texts.length === 0) {
262
+ return { embeddings: [], model: this.model, promptTokens: 0, totalTokens: 0 }
263
+ }
264
+
265
+ // Defensive: embedding providers reject empty strings outright (live
266
+ // crash 2026-08-16 — OpenRouter HTTP 400 "too_small"). buildIndex
267
+ // filters these before calling, but any other caller must get a clear
268
+ // diagnostic here, not a cryptic 400 from upstream.
269
+ const emptyIndex = texts.findIndex((t) => t.trim() === "")
270
+ if (emptyIndex !== -1) {
271
+ throw new Error(`embedder: cannot embed empty/whitespace-only input at index ${emptyIndex} — providers reject it`)
272
+ }
273
+
274
+ const embeddings: number[][] = []
275
+ let promptTokens = 0
276
+ let totalTokens = 0
277
+
278
+ for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) {
279
+ const batch = texts.slice(i, i + EMBED_BATCH_SIZE)
280
+ let attempt = 0
281
+ let lastError: unknown
282
+ while (attempt < MAX_RETRIES) {
283
+ try {
284
+ const result = await this.client.embed(batch, this.model, { provider: this.provider })
285
+ embeddings.push(...result.embeddings)
286
+ promptTokens += result.promptTokens
287
+ totalTokens += result.totalTokens
288
+ lastError = undefined
289
+ break
290
+ } catch (err) {
291
+ lastError = err
292
+ const status =
293
+ err instanceof Error && "status" in err ? (err as { status?: number }).status : undefined
294
+ const isTransient = status === 429 || status === 502 || status === 503
295
+ if (!isTransient || attempt >= MAX_RETRIES - 1) {
296
+ throw err
297
+ }
298
+ await new Promise((r) => setTimeout(r, INITIAL_RETRY_DELAY_MS * 2 ** attempt))
299
+ attempt++
300
+ }
301
+ }
302
+ if (lastError) {
303
+ throw lastError
304
+ }
305
+ }
306
+
307
+ return { embeddings, model: this.model, promptTokens, totalTokens }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Compute cosine similarity between two vectors. Handles different lengths by
313
+ * treating missing entries as 0 (index entries are all the same model's
314
+ * dimension in practice, but a mixed old/new index should not crash).
315
+ */
316
+ export function cosineSimilarity(a: number[], b: number[]): number {
317
+ let dot = 0
318
+ let normA = 0
319
+ let normB = 0
320
+ const len = Math.max(a.length, b.length)
321
+ for (let i = 0; i < len; i++) {
322
+ const av = a[i] ?? 0
323
+ const bv = b[i] ?? 0
324
+ dot += av * bv
325
+ normA += av * av
326
+ normB += bv * bv
327
+ }
328
+ if (normA === 0 || normB === 0) {
329
+ return 0
330
+ }
331
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB))
332
+ }