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.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
package/src/init/cli.ts
ADDED
|
@@ -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
|
+
}
|