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,248 @@
1
+ /**
2
+ * `headlesscode init` subcommand — one-command project registration.
3
+ *
4
+ * npx tsx src/cli.ts init --workspace <path> [--skip-index] [--skip-codemap]
5
+ * [--embedding-backend <openrouter|ollama|airunner>]
6
+ *
7
+ * A thin orchestration layer over EXISTING functionality: project registration
8
+ * (resolveProjectDataDir), stack detection (detectStacks), .gitignore hygiene
9
+ * (ensureWorkspaceGitignore), the codesearch index (indexMain) and the codemap
10
+ * (codemapMain). It must not reimplement any of those — each step shells into
11
+ * the same code path its standalone subcommand uses (see
12
+ * plans/project-init-registration.md).
13
+ */
14
+
15
+ import * as fs from "node:fs"
16
+ import * as path from "node:path"
17
+
18
+ import { resolveProjectDataDir } from "../project-store.js"
19
+ import { detectStacks, getCentralStackRulesFile } from "../engine/stacks.js"
20
+ import { indexMain } from "../codesearch/cli.js"
21
+ import { loadIndex } from "../codesearch/index.js"
22
+ import { codemapMain } from "../codemap/cli.js"
23
+ import { ensureWorkspaceGitignore } from "./gitignore.js"
24
+
25
+ const INIT_USAGE = `headlesscode init — one-command project registration
26
+
27
+ Usage:
28
+ headlesscode init --workspace <path> [--skip-index] [--skip-codemap]
29
+ [--embedding-backend <openrouter|ollama|airunner>]
30
+
31
+ Options:
32
+ --workspace <path> Workspace root to register (required; must be a
33
+ directory)
34
+ --skip-index Do not build/refresh the codebase-search index
35
+ (saves embedding API cost)
36
+ --skip-codemap Do not build/refresh the codemap
37
+ --embedding-backend <b> Embedding backend for the index step: openrouter
38
+ (default), ollama (local), or airunner (local
39
+ AIRunner server). Passed straight through to
40
+ \`headlesscode index\`
41
+ --help Show this help and exit
42
+
43
+ Registers a new project with headlesscode in one step: resolves the central
44
+ per-project data dir (~/.local/share/headlesscode/projects/<key>), detects
45
+ the project's stack(s) (stack-aware instructions are spliced into every
46
+ session automatically — see docs/stack-rules.md), makes sure the workspace's
47
+ .gitignore excludes .headlesscode/ session artifacts, then builds the
48
+ codebase-search index and the codemap. Each step is independent: a failure in
49
+ one is reported inline and the remaining steps still run, but the command
50
+ exits non-zero.
51
+
52
+ The index step costs real money (embedding API calls) unless a local backend
53
+ (--embedding-backend ollama or airunner) is used or the step is skipped with
54
+ --skip-index.
55
+ `
56
+
57
+ interface InitCliOptions {
58
+ workspace?: string
59
+ skipIndex: boolean
60
+ skipCodemap: boolean
61
+ embeddingBackend?: string
62
+ help: boolean
63
+ }
64
+
65
+ export function parseInitArgs(argv: string[]): { options: InitCliOptions; error?: string } {
66
+ const options: InitCliOptions = { skipIndex: false, skipCodemap: false, help: false }
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const arg = argv[i]
69
+ const eq = arg.indexOf("=")
70
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
71
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
72
+ const next = (): string | undefined => {
73
+ if (inlineValue !== undefined) {
74
+ return inlineValue
75
+ }
76
+ const v = argv[i + 1]
77
+ if (v === undefined || v.startsWith("--")) {
78
+ return undefined
79
+ }
80
+ i++
81
+ return v
82
+ }
83
+ switch (flag) {
84
+ case "--workspace":
85
+ case "--embedding-backend": {
86
+ const value = next()
87
+ if (value === undefined) {
88
+ return { options, error: `Missing value for ${flag}` }
89
+ }
90
+ if (flag === "--workspace") {
91
+ options.workspace = value
92
+ } else {
93
+ options.embeddingBackend = value
94
+ }
95
+ break
96
+ }
97
+ case "--skip-index":
98
+ options.skipIndex = true
99
+ break
100
+ case "--skip-codemap":
101
+ options.skipCodemap = true
102
+ break
103
+ case "--help":
104
+ case "-h":
105
+ options.help = true
106
+ break
107
+ default:
108
+ return { options, error: `Unknown argument: ${arg}` }
109
+ }
110
+ }
111
+ return { options }
112
+ }
113
+
114
+ export async function initMain(argv: string[]): Promise<number> {
115
+ const { options, error } = parseInitArgs(argv)
116
+ if (error) {
117
+ process.stderr.write(`headlesscode init: ${error}\n\n${INIT_USAGE}`)
118
+ return 2
119
+ }
120
+ if (options.help) {
121
+ process.stdout.write(INIT_USAGE)
122
+ return 0
123
+ }
124
+ if (!options.workspace) {
125
+ process.stderr.write(`headlesscode init: --workspace <path> is required\n\n${INIT_USAGE}`)
126
+ return 2
127
+ }
128
+
129
+ const workspaceRoot = path.resolve(options.workspace)
130
+ let isDir = false
131
+ try {
132
+ isDir = fs.statSync(workspaceRoot).isDirectory()
133
+ } catch {
134
+ isDir = false
135
+ }
136
+ if (!isDir) {
137
+ process.stderr.write(`headlesscode init: --workspace is not a directory: ${workspaceRoot}\n\n${INIT_USAGE}`)
138
+ return 2
139
+ }
140
+
141
+ process.stdout.write("headlesscode init — one-command project registration\n\n")
142
+
143
+ // Step 1: resolve + register (the existing, sole registration mechanism —
144
+ // writes <central>/project.json on first contact). `registered: true` is
145
+ // ONLY ever set here — no other call site in the codebase passes it, so a
146
+ // human-deliberate registration is the single thing that flips the flag.
147
+ const centralDir = resolveProjectDataDir(workspaceRoot, { registered: true })
148
+ process.stdout.write(
149
+ "Step 1/5 — project registration\n" +
150
+ ` workspace: ${workspaceRoot}\n` +
151
+ ` central data dir: ${centralDir}\n\n`,
152
+ )
153
+
154
+ // Step 2: detect stacks (per-session detection is what actually drives
155
+ // instruction selection — this is the one place it's surfaced to a human).
156
+ const stacks = await detectStacks(workspaceRoot)
157
+ process.stdout.write("Step 2/5 — stack detection\n")
158
+ if (stacks.size === 0) {
159
+ process.stdout.write(" no recognized stack — generic instructions only\n")
160
+ } else {
161
+ process.stdout.write(` detected: ${[...stacks].join(", ")}\n`)
162
+ for (const stack of stacks) {
163
+ const rulesFile = getCentralStackRulesFile(stack)
164
+ if (fs.existsSync(rulesFile)) {
165
+ process.stdout.write(` ${stack}: rules at ${rulesFile}\n`)
166
+ } else {
167
+ process.stdout.write(
168
+ ` ${stack}: no central rules content yet — consider adding ${rulesFile}\n`,
169
+ )
170
+ }
171
+ }
172
+ }
173
+ process.stdout.write("\n")
174
+
175
+ // Step 3: ensure the target repo's .gitignore excludes .headlesscode/.
176
+ process.stdout.write("Step 3/5 — .gitignore\n")
177
+ const gitignoreResult = ensureWorkspaceGitignore(workspaceRoot)
178
+ const gitignoreActionText =
179
+ gitignoreResult.action === "created"
180
+ ? "created with /.headlesscode/ entry"
181
+ : gitignoreResult.action === "appended"
182
+ ? "appended /.headlesscode/ entry (existing content untouched)"
183
+ : "already covers .headlesscode — no change"
184
+ process.stdout.write(` ${gitignoreResult.path}: ${gitignoreActionText}\n\n`)
185
+
186
+ // Steps 4-5: index + codemap. Each is independent: a failure is reported
187
+ // inline and the later step still runs, but the command exits non-zero.
188
+ let exitCode = 0
189
+ let indexSummary = "skipped by flag"
190
+ let codemapSummary = "skipped by flag"
191
+
192
+ process.stdout.write("Step 4/5 — codebase index\n")
193
+ if (!options.skipIndex) {
194
+ const indexArgs = ["--workspace", workspaceRoot]
195
+ if (options.embeddingBackend) {
196
+ indexArgs.push("--embedding-backend", options.embeddingBackend)
197
+ }
198
+ try {
199
+ // Same code path as `headlesscode index` — including its
200
+ // cost/token summary (real embedding API spend).
201
+ const code = await indexMain(indexArgs)
202
+ if (code !== 0) {
203
+ exitCode = 1
204
+ indexSummary = `FAILED (exit ${code})`
205
+ } else {
206
+ indexSummary = `ok — ${loadIndex(workspaceRoot).length} chunks in index`
207
+ }
208
+ } catch (err) {
209
+ exitCode = 1
210
+ indexSummary = `FAILED (${err instanceof Error ? err.message : String(err)})`
211
+ }
212
+ } else {
213
+ process.stdout.write(" skipped by flag\n")
214
+ }
215
+ process.stdout.write("\n")
216
+
217
+ process.stdout.write("Step 5/5 — codemap\n")
218
+ if (!options.skipCodemap) {
219
+ try {
220
+ const code = await codemapMain(["--workspace", workspaceRoot])
221
+ if (code !== 0) {
222
+ exitCode = 1
223
+ codemapSummary = `FAILED (exit ${code})`
224
+ } else {
225
+ codemapSummary = "ok (see step 5 output above)"
226
+ }
227
+ } catch (err) {
228
+ exitCode = 1
229
+ codemapSummary = `FAILED (${err instanceof Error ? err.message : String(err)})`
230
+ }
231
+ } else {
232
+ process.stdout.write(" skipped by flag\n")
233
+ }
234
+ process.stdout.write("\n")
235
+
236
+ // Step 6: final summary.
237
+ process.stdout.write(
238
+ "Summary\n" +
239
+ ` workspace: ${workspaceRoot}\n` +
240
+ ` central data dir: ${centralDir}\n` +
241
+ ` stacks: ${stacks.size === 0 ? "none (generic instructions only)" : [...stacks].join(", ")}\n` +
242
+ ` gitignore: ${gitignoreResult.action}\n` +
243
+ ` index: ${indexSummary}\n` +
244
+ ` codemap: ${codemapSummary}\n`,
245
+ )
246
+
247
+ return exitCode
248
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `headlesscode init`'s .gitignore hygiene: ensure a target repo's
3
+ * `.gitignore` excludes `.headlesscode/` so workspace-relative session
4
+ * artifacts (events, reports, usage, scratch, memory) can never be
5
+ * accidentally committed by a human `git add -A` after a first run.
6
+ */
7
+
8
+ import * as fs from "node:fs"
9
+ import * as path from "node:path"
10
+
11
+ export type GitignoreActionResult =
12
+ | { action: "created"; path: string }
13
+ | { action: "appended"; path: string }
14
+ | { action: "unchanged"; path: string }
15
+
16
+ /**
17
+ * True when a `.gitignore` already covers `.headlesscode`. Accepts any of the
18
+ * four common spellings (`.headlesscode/`, `/.headlesscode/`, `.headlesscode`,
19
+ * `/.headlesscode`) plus a bare `*` — the same slash-normalizing,
20
+ * segment-matching idiom src/codesearch/files.ts's fallback matcher uses.
21
+ */
22
+ function gitignoreCoversHeadlesscode(content: string): boolean {
23
+ for (const line of content.split("\n")) {
24
+ const trimmed = line.trim()
25
+ if (trimmed === "" || trimmed.startsWith("#")) {
26
+ continue
27
+ }
28
+ const p = trimmed.replace(/^\/+/, "").replace(/\/+$/, "")
29
+ if (p === ".headlesscode" || p === "*") {
30
+ return true
31
+ }
32
+ }
33
+ return false
34
+ }
35
+
36
+ /**
37
+ * Ensure `<workspace>/.gitignore` ignores `.headlesscode/`:
38
+ * - file absent → created with the entry;
39
+ * - file present but not covering it → entry appended (existing content
40
+ * preserved byte-for-byte, no reformatting or reordering);
41
+ * - already covered → untouched.
42
+ *
43
+ * Never touches the filesystem when the workspace is not a real directory
44
+ * (a missing workspace is `init`'s job to fail on, not this helper's to
45
+ * silently create a `.gitignore` for).
46
+ */
47
+ export function ensureWorkspaceGitignore(workspaceRoot: string): GitignoreActionResult {
48
+ const gitignorePath = path.join(workspaceRoot, ".gitignore")
49
+ try {
50
+ if (!fs.statSync(workspaceRoot).isDirectory()) {
51
+ return { action: "unchanged", path: gitignorePath }
52
+ }
53
+ } catch {
54
+ return { action: "unchanged", path: gitignorePath }
55
+ }
56
+
57
+ let existing = ""
58
+ let existed = false
59
+ try {
60
+ existing = fs.readFileSync(gitignorePath, "utf-8")
61
+ existed = true
62
+ } catch {
63
+ // Missing .gitignore — start from empty.
64
+ }
65
+
66
+ if (gitignoreCoversHeadlesscode(existing)) {
67
+ return { action: "unchanged", path: gitignorePath }
68
+ }
69
+
70
+ const entry = "# headlesscode session artifacts (events, reports, usage, scratch, memory)\n/.headlesscode/\n"
71
+ const content = existing === "" || existing.endsWith("\n") ? existing + entry : existing + "\n" + entry
72
+ fs.writeFileSync(gitignorePath, content, "utf-8")
73
+ return { action: existed ? "appended" : "created", path: gitignorePath }
74
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Ollama-compatible local LLM client — implements the full `LlmClient`
3
+ * interface (not the narrow read-only `LocalChatClient` in
4
+ * src/engine/local-explore.ts) so a session's `code` mode can run entirely
5
+ * against a local model instead of OpenRouter. See
6
+ * plans/local-dual-model-code-agent.md (Phase 2 step 4) and
7
+ * plans/local-code-mode-trial.md for the design this implements.
8
+ *
9
+ * Reuses the wire-format findings already verified live against Ollama
10
+ * 0.24.0 / the local daemon's ollama-compat route in
11
+ * src/engine/local-explore.ts's OllamaLocalChatClient:
12
+ * - `function.arguments` must be a parsed OBJECT on the wire in both
13
+ * directions (Ollama rejects the OpenAI/OpenRouter JSON-string form).
14
+ * - `think` previously had to be hardcoded `false`: a prior local model
15
+ * was observed leaving `message.content` empty and putting the reply in
16
+ * `message.thinking` when thinking was on. Re-verified live 2026-08-20
17
+ * against Qwen3-14B (the first thinking-capable local model this harness
18
+ * has run) on both a plain reply and a tool call — `content`/`tool_calls`
19
+ * came back correctly populated with `think: true`, so that failure mode
20
+ * doesn't reproduce for this model. Rather than flip the default (and
21
+ * risk the original model that motivated `false`), `think` is now
22
+ * configurable per client instance — see `OLLAMA_THINK_ENV`.
23
+ * Cost is always $0 (no OpenRouter billing involved) but real token counts
24
+ * are still reported via `usage` so budget/report accounting stays accurate.
25
+ *
26
+ * `node_id` continuity (found 2026-08-27 debugging a live daemon): the
27
+ * airunnerdesktop daemon's `/api/chat` route is NOT actually stateless
28
+ * despite implementing the Ollama wire protocol — `_get_or_create_conversation`
29
+ * (conversation_management_mixin.py) opens a BRAND NEW, empty DB-backed
30
+ * conversation on every call unless the request body carries a stable
31
+ * `node_id` string, which this client never sent. Verified live: without
32
+ * `node_id`, every call got a fresh `conversation_id` (confirmed via the
33
+ * daemon's own logs incrementing 5912 -> 5913 one call apart) with zero
34
+ * prior messages, so on any turn after the first — once the daemon's own
35
+ * chat-template tries to render a multi-turn tool-calling exchange whose
36
+ * "history" is actually empty — the underlying jinja2 chat template raises
37
+ * `TemplateError: No user query found in messages` (the DB truly has no
38
+ * user-role message, since only the single newest turn — a tool result,
39
+ * not the original user turn — ever got attached to that fresh
40
+ * conversation). One real request in isolation "works" (a fresh
41
+ * conversation containing exactly the current user message renders fine),
42
+ * which is why this was easy to misdiagnose as a context-window or
43
+ * capability issue rather than a continuity bug. Sending a stable `node_id`
44
+ * (generated once per `OllamaClient` instance, i.e. once per headlesscode
45
+ * session) makes the daemon reuse the SAME conversation record across every
46
+ * call in a session, matching how a real chat UI like uwuchat's own
47
+ * LangChain-based client drives this same daemon successfully.
48
+ */
49
+
50
+ import { randomUUID } from "node:crypto"
51
+ import { Agent, fetch as undiciFetch } from "undici"
52
+ import type { ChatMessage, ChatToolCall, LlmClient, LlmRequest, LlmResponse } from "../engine/types.js"
53
+ import { captureTranscript, isTranscriptCaptureEnabled } from "./transcript-capture.js"
54
+
55
+ export const OLLAMA_URL_ENV = "HEADLESSCODE_OLLAMA_URL"
56
+ export const DEFAULT_OLLAMA_URL = "http://localhost:11434"
57
+ export const DEFAULT_OLLAMA_TIMEOUT_MS = 300_000
58
+ /** Opt a thinking-capable local model into `think: true` (default false — see the module doc comment). */
59
+ export const OLLAMA_THINK_ENV = "HEADLESSCODE_OLLAMA_THINK"
60
+
61
+ export interface OllamaClientOptions {
62
+ baseUrl?: string
63
+ defaultModel?: string
64
+ timeoutMs?: number
65
+ fetchImpl?: typeof fetch
66
+ /** Send `think: true` to the daemon (default false). See `OLLAMA_THINK_ENV`. */
67
+ think?: boolean
68
+ /** Override the `node_id` sent with every request (default: a fresh UUID per client instance). See the module doc comment. */
69
+ nodeId?: string
70
+ }
71
+
72
+ /** Typed error for a non-2xx response or a malformed payload from the daemon. */
73
+ export class OllamaError extends Error {
74
+ readonly status?: number
75
+ readonly body?: string
76
+
77
+ constructor(message: string, status?: number, body?: string) {
78
+ super(message)
79
+ this.name = "OllamaError"
80
+ this.status = status
81
+ this.body = body
82
+ }
83
+ }
84
+
85
+ function envThinkEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
86
+ const v = env[OLLAMA_THINK_ENV]
87
+ return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false"
88
+ }
89
+
90
+ export class OllamaClient implements LlmClient {
91
+ private readonly baseUrl: string
92
+ private readonly defaultModel: string
93
+ private readonly timeoutMs: number
94
+ private readonly fetchImpl: typeof fetch
95
+ private readonly think: boolean
96
+ /** Stable per-client identifier sent as `node_id` so a stateful daemon (e.g. airunnerdesktop) reuses one conversation for this session instead of a fresh, history-less one per call. See the module doc comment. */
97
+ private readonly nodeId: string
98
+ /**
99
+ * Node's global `fetch` runs on an undici HTTP client with its OWN
100
+ * independent default timeouts (`headersTimeout`/`bodyTimeout`, 300s
101
+ * each) that fire regardless of the `AbortSignal` passed to fetch() —
102
+ * verified live 2026-08-28 (joeos issue #26, round 7): with `timeoutMs`
103
+ * already raised past 300s via the constructor option AND the request's
104
+ * own AbortController set to fire at that later value, a session still
105
+ * died at ~300-330s with a generic "fetch failed" (not the "timed out
106
+ * after Xms" message this class produces itself on a real AbortError —
107
+ * proof it wasn't `this.timeoutMs`'s own timer that fired first). A
108
+ * real prefill+generation call against a 9B local model at deep prompt
109
+ * lengths can legitimately take longer than undici's default assumes.
110
+ *
111
+ * Overriding this requires a per-instance `Agent` with both timeouts
112
+ * derived from `this.timeoutMs` (comfortable margin above it) — but
113
+ * passing an `Agent` built from the standalone `undici` npm package as
114
+ * Node's GLOBAL `fetch`'s `dispatcher` does not reliably work: verified
115
+ * live, this threw `InvalidArgumentError: invalid onRequestStart
116
+ * method` (code UND_ERR_INVALID_ARG) — Node's global fetch runs on its
117
+ * OWN internal, bundled copy of undici (`node:internal/deps/undici`),
118
+ * whose Dispatcher/Handler interface isn't guaranteed to match whatever
119
+ * version the standalone package resolves to. The fix is to use
120
+ * undici's own `fetch` (imported below as `undiciFetch`) together with
121
+ * its own `Agent`, so both come from the SAME package/version and the
122
+ * interface always matches — never Node's global `fetch` plus an
123
+ * externally-built dispatcher.
124
+ */
125
+ private readonly dispatcher: Agent
126
+
127
+ constructor(options: OllamaClientOptions = {}) {
128
+ this.baseUrl = (options.baseUrl ?? process.env[OLLAMA_URL_ENV] ?? DEFAULT_OLLAMA_URL).replace(/\/+$/, "")
129
+ this.defaultModel = options.defaultModel ?? ""
130
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_OLLAMA_TIMEOUT_MS
131
+ // Default to undici's OWN fetch, not Node's global one — see
132
+ // `dispatcher`'s doc comment for why: only undici's own fetch is
133
+ // guaranteed interface-compatible with an Agent built from the same
134
+ // package. `fetchImpl`'s public option type stays `typeof fetch`
135
+ // (structurally close enough for test fakes to satisfy); the cast
136
+ // here just reconciles undici's own Response/RequestInit types
137
+ // against the DOM-lib ones the public field declares.
138
+ const defaultFetchImpl: unknown = (url: unknown, init: unknown) =>
139
+ undiciFetch(url as Parameters<typeof undiciFetch>[0], init as Parameters<typeof undiciFetch>[1])
140
+ this.fetchImpl = options.fetchImpl ?? (defaultFetchImpl as typeof fetch)
141
+ this.think = options.think ?? envThinkEnabled()
142
+ this.nodeId = options.nodeId ?? randomUUID()
143
+ const undiciTimeoutMs = this.timeoutMs + 60_000
144
+ this.dispatcher = new Agent({ headersTimeout: undiciTimeoutMs, bodyTimeout: undiciTimeoutMs })
145
+ }
146
+
147
+ resolveModel(requestModel?: string): string {
148
+ return requestModel?.trim() || this.defaultModel
149
+ }
150
+
151
+ async createChatCompletion(request: LlmRequest): Promise<LlmResponse> {
152
+ const model = this.resolveModel(request.model)
153
+ if (!model) {
154
+ throw new OllamaError("OllamaClient: no model resolved (pass request.model or defaultModel)")
155
+ }
156
+ const startedAt = Date.now()
157
+
158
+ const controller = new AbortController()
159
+ const onAbort = () => controller.abort()
160
+ request.signal?.addEventListener("abort", onAbort)
161
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs)
162
+
163
+ try {
164
+ const res = await this.fetchImpl(`${this.baseUrl}/api/chat`, {
165
+ method: "POST",
166
+ headers: { "content-type": "application/json" },
167
+ signal: controller.signal,
168
+ // See `dispatcher`'s doc comment: `this.fetchImpl` defaults to
169
+ // undici's own fetch (bridged through a `typeof fetch`-typed
170
+ // wrapper for public-API/test compatibility), which DOES
171
+ // understand a per-call `dispatcher` the same way undici's
172
+ // Agent-based clients do — this cast just reconciles that
173
+ // against the DOM-lib `RequestInit` type this call site is
174
+ // statically typed against. A fake fetchImpl injected in
175
+ // tests simply ignores this extra field.
176
+ dispatcher: this.dispatcher as unknown as RequestInit["dispatcher"],
177
+ body: JSON.stringify({
178
+ model,
179
+ node_id: this.nodeId,
180
+ messages: toOllamaMessages(request.messages),
181
+ stream: false,
182
+ think: this.think,
183
+ tools: request.tools,
184
+ ...(request.toolChoice !== undefined ? { tool_choice: request.toolChoice } : {}),
185
+ options: {
186
+ temperature: request.temperature ?? 0,
187
+ ...(request.maxTokens !== undefined ? { num_predict: request.maxTokens } : {}),
188
+ ...(request.repeatPenalty !== undefined ? { repeat_penalty: request.repeatPenalty } : {}),
189
+ },
190
+ }),
191
+ })
192
+ if (!res.ok) {
193
+ const body = await res.text().catch(() => "")
194
+ throw new OllamaError(`Ollama /api/chat returned HTTP ${res.status}: ${body.slice(0, 500)}`, res.status, body)
195
+ }
196
+ const data = (await res.json()) as {
197
+ message?: {
198
+ role?: string
199
+ content?: string | null
200
+ thinking?: string | null
201
+ tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: unknown } }>
202
+ }
203
+ prompt_eval_count?: number
204
+ eval_count?: number
205
+ }
206
+ if (!data.message) {
207
+ throw new OllamaError("Ollama /api/chat response had no message field")
208
+ }
209
+ const toolCalls: ChatToolCall[] = (data.message.tool_calls ?? []).map((c, i) => ({
210
+ id: c.id ?? `call_${i}`,
211
+ type: "function" as const,
212
+ function: {
213
+ name: c.function?.name ?? "",
214
+ arguments: normalizeToolCallArguments(c.function?.arguments),
215
+ },
216
+ }))
217
+ const message: ChatMessage = {
218
+ role: "assistant",
219
+ content: data.message.content ?? null,
220
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
221
+ ...(data.message.thinking ? { reasoning: data.message.thinking } : {}),
222
+ }
223
+ const response: LlmResponse = {
224
+ message,
225
+ usage: {
226
+ promptTokens: data.prompt_eval_count ?? 0,
227
+ completionTokens: data.eval_count ?? 0,
228
+ totalTokens: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
229
+ },
230
+ }
231
+ if (isTranscriptCaptureEnabled()) {
232
+ captureTranscript(
233
+ { provider: "ollama", model },
234
+ { messages: request.messages, tools: request.tools, temperature: request.temperature, response, durationMs: Date.now() - startedAt },
235
+ )
236
+ }
237
+ return response
238
+ } catch (err) {
239
+ if (isTranscriptCaptureEnabled()) {
240
+ captureTranscript(
241
+ { provider: "ollama", model },
242
+ {
243
+ messages: request.messages,
244
+ tools: request.tools,
245
+ temperature: request.temperature,
246
+ error: err instanceof Error ? err.message : String(err),
247
+ durationMs: Date.now() - startedAt,
248
+ },
249
+ )
250
+ }
251
+ if (err instanceof OllamaError) {
252
+ throw err
253
+ }
254
+ if (err instanceof Error && err.name === "AbortError") {
255
+ throw new OllamaError(`Ollama /api/chat timed out after ${this.timeoutMs}ms`)
256
+ }
257
+ throw new OllamaError(
258
+ `Ollama /api/chat request failed: ${err instanceof Error ? err.message : String(err)} (is the daemon running at ${this.baseUrl}? is ${model} loaded?)`,
259
+ )
260
+ } finally {
261
+ clearTimeout(timer)
262
+ request.signal?.removeEventListener("abort", onAbort)
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Engine ChatMessage[] → Ollama wire format (tool_call arguments as OBJECTS, tool schemas untouched). */
268
+ function toOllamaMessages(messages: ChatMessage[]): unknown[] {
269
+ return messages.map((m) => {
270
+ if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) {
271
+ return {
272
+ role: "assistant",
273
+ content: m.content ?? "",
274
+ tool_calls: m.tool_calls.map((c) => ({
275
+ id: c.id,
276
+ type: "function",
277
+ function: { name: c.function.name, arguments: toOllamaArgs(c.function.arguments) },
278
+ })),
279
+ }
280
+ }
281
+ if (m.role === "tool") {
282
+ return { role: "tool", content: m.content ?? "", tool_call_id: m.tool_call_id, name: m.name }
283
+ }
284
+ return { role: m.role, content: m.content ?? "" }
285
+ })
286
+ }
287
+
288
+ /** Engine arguments JSON string → Ollama object. Parse failure → {} (Ollama rejects strings here). */
289
+ function toOllamaArgs(args: string): unknown {
290
+ try {
291
+ return JSON.parse(args)
292
+ } catch {
293
+ return {}
294
+ }
295
+ }
296
+
297
+ /** Ollama arguments (object, or occasionally string) → engine JSON-string form. */
298
+ function normalizeToolCallArguments(args: unknown): string {
299
+ if (typeof args === "string") {
300
+ try {
301
+ JSON.parse(args)
302
+ return args
303
+ } catch {
304
+ return JSON.stringify({ raw: args })
305
+ }
306
+ }
307
+ return JSON.stringify(args ?? {})
308
+ }