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