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,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session structured event feed — live worker monitoring (Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/engine/usage.ts`'s idiom exactly: append-only JSONL, one file
|
|
5
|
+
* per session, loose validation on read, plain `node:fs` — no schema library.
|
|
6
|
+
*
|
|
7
|
+
* <workspaceRoot>/.headlesscode/events/<sessionId>.jsonl
|
|
8
|
+
*
|
|
9
|
+
* Each line is one `EventRecord`:
|
|
10
|
+
*
|
|
11
|
+
* { ts: ISO string, sessionId: string, type: string, ...fields }
|
|
12
|
+
*
|
|
13
|
+
* Event types emitted by HeadlessSession (see src/engine/loop.ts):
|
|
14
|
+
* session_start / iteration_start / llm_response / tool_call / tool_result
|
|
15
|
+
* checkpoint_saved / decision_blocked / decision_answered
|
|
16
|
+
* condensed / todo_updated / paused / resumed / session_end
|
|
17
|
+
* attempt_completion (issue #34): the session's FINAL report, emitted when
|
|
18
|
+
* the loop accepts completion (attempt_completion tool call OR the
|
|
19
|
+
* text-only success fallback) and carries the FULL report text — this is
|
|
20
|
+
* the deliberate exception to EVENT_TRUNCATE_CHARS: the report is exactly
|
|
21
|
+
* the content the model produced, so truncating it would reintroduce the
|
|
22
|
+
* observability gap it exists to close (a surprising review/QA verdict was
|
|
23
|
+
* previously unrecoverable without re-running the whole session). The
|
|
24
|
+
* full report is ALSO persisted to `<workspaceRoot>/.headlesscode/reports/
|
|
25
|
+
* <sessionId>.md` (see src/engine/reports.ts) so it survives even a feed
|
|
26
|
+
* that never gets polled.
|
|
27
|
+
* mode_switched (switch_mode: the session's own active mode changed in
|
|
28
|
+
* place — same session, new system prompt/tool set; see
|
|
29
|
+
* plans/switch-mode-headless.md)
|
|
30
|
+
* message_injected (live chat-UI control: a human wrote a new user message
|
|
31
|
+
* into the RUNNING session via the dashboard — POST /api/session/:id/message
|
|
32
|
+
* → `.harness.inject-message` marker; see loop.ts's checkInjectedMessage.
|
|
33
|
+
* The text has been appended to the session's live history as a plain
|
|
34
|
+
* user-role message and the next LLM call sees it)
|
|
35
|
+
*
|
|
36
|
+
* `condensed` is the one structural event that was originally only a log line
|
|
37
|
+
* (`[condense] oldest turns condensed…` in src/engine/condense.ts) — the
|
|
38
|
+
* dashboard's timeline view needs it as a real event to mark where the
|
|
39
|
+
* session compressed its history, so it is emitted by HeadlessSession when a
|
|
40
|
+
* condensation pass actually replaces the oldest turns.
|
|
41
|
+
*
|
|
42
|
+
* `todo_updated` (update_todo_list: full normalized checklist + counts —
|
|
43
|
+
* done / inProgress / pending; the dashboard reads this to render live
|
|
44
|
+
* planning state)
|
|
45
|
+
*
|
|
46
|
+
* Unlike the live usage snapshot (deleted at session end because the final
|
|
47
|
+
* .jsonl record supersedes it), the event feed is a history/replay log and is
|
|
48
|
+
* KEPT after the session ends — it is never deleted in session teardown.
|
|
49
|
+
*
|
|
50
|
+
* This module is the ONLY place that knows the on-disk events layout, so the
|
|
51
|
+
* dashboard read side (`src/dashboard/aggregate.ts`'s `readSessionEvents`)
|
|
52
|
+
* and the write side (`EventFeed` below) stay in sync.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import * as fsp from "node:fs/promises"
|
|
56
|
+
import * as path from "node:path"
|
|
57
|
+
|
|
58
|
+
/** Cap on any single event field's string content (this feed is polled frequently). */
|
|
59
|
+
export const EVENT_TRUNCATE_CHARS = 500
|
|
60
|
+
|
|
61
|
+
/** One line of the per-session event feed. */
|
|
62
|
+
export interface EventRecord {
|
|
63
|
+
ts: string
|
|
64
|
+
sessionId: string
|
|
65
|
+
type: string
|
|
66
|
+
iteration?: number
|
|
67
|
+
/**
|
|
68
|
+
* Recursive task decomposition (`new_task`): the id of the session that
|
|
69
|
+
* spawned this one, present on every event of a child session (absent on
|
|
70
|
+
* root sessions). Lets the dashboard render parent/child nesting without
|
|
71
|
+
* cross-referencing anything else — the lineage is stamped onto each
|
|
72
|
+
* record, so a child feed never needs its parent's feed to be readable.
|
|
73
|
+
*/
|
|
74
|
+
parentSessionId?: string
|
|
75
|
+
/**
|
|
76
|
+
* Recursive task decomposition (`new_task`): this session's recursion
|
|
77
|
+
* depth (0 = root). Stamped onto every event alongside parentSessionId.
|
|
78
|
+
*/
|
|
79
|
+
recursionDepth?: number
|
|
80
|
+
[field: string]: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The events dir for a workspace: `<workspaceRoot>/.headlesscode/events`. */
|
|
84
|
+
export function eventsDir(workspaceRoot: string): string {
|
|
85
|
+
return path.join(workspaceRoot, ".headlesscode", "events")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The event feed file path for one session. */
|
|
89
|
+
export function eventsFilePath(workspaceRoot: string, sessionId: string): string {
|
|
90
|
+
return path.join(eventsDir(workspaceRoot), `${sessionId}.jsonl`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Append one event record to a session's feed. Creates the events dir if
|
|
95
|
+
* needed. Callers are expected to wrap this in try/catch and treat failures
|
|
96
|
+
* as non-fatal (see `EventFeed`); this function itself does not swallow
|
|
97
|
+
* errors, so it fails loudly for direct callers/tests.
|
|
98
|
+
*/
|
|
99
|
+
export async function appendEvent(workspaceRoot: string, sessionId: string, record: EventRecord): Promise<void> {
|
|
100
|
+
const file = eventsFilePath(workspaceRoot, sessionId)
|
|
101
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
102
|
+
await fsp.appendFile(file, JSON.stringify(record) + "\n", "utf-8")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read + loosely validate one event feed JSONL file. Malformed /
|
|
107
|
+
* partially-written lines are skipped; a missing file yields an empty array
|
|
108
|
+
* (never throws for ENOENT — matches the usage store's `readUsageFile` idiom).
|
|
109
|
+
*/
|
|
110
|
+
export async function readEventsFile(file: string): Promise<EventRecord[]> {
|
|
111
|
+
let raw: string
|
|
112
|
+
try {
|
|
113
|
+
raw = await fsp.readFile(file, "utf-8")
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
const records: EventRecord[] = []
|
|
121
|
+
for (const line of raw.split("\n")) {
|
|
122
|
+
const trimmed = line.trim()
|
|
123
|
+
if (!trimmed) {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(trimmed) as Partial<EventRecord>
|
|
128
|
+
if (
|
|
129
|
+
typeof parsed.ts === "string" &&
|
|
130
|
+
typeof parsed.sessionId === "string" &&
|
|
131
|
+
typeof parsed.type === "string"
|
|
132
|
+
) {
|
|
133
|
+
records.push(parsed as EventRecord)
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Loose validation: skip malformed / partially-written lines.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return records
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A string field that was possibly truncated to `EVENT_TRUNCATE_CHARS`. */
|
|
143
|
+
export interface TruncatedField {
|
|
144
|
+
text: string
|
|
145
|
+
truncated: boolean
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Truncate a long string for the event feed, flagging when it was cut. */
|
|
149
|
+
export function truncateField(value: string, max = EVENT_TRUNCATE_CHARS): TruncatedField {
|
|
150
|
+
if (value.length <= max) {
|
|
151
|
+
return { text: value, truncated: false }
|
|
152
|
+
}
|
|
153
|
+
return { text: value.slice(0, max), truncated: true }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The session's structured event writer. Every emit is non-fatal: a failure
|
|
158
|
+
* to append is reported via the injected `onError` callback (the loop wires
|
|
159
|
+
* it to `logger.warn`, matching the established memory/usage/checkpoint
|
|
160
|
+
* try/catch idiom) and never affects the session.
|
|
161
|
+
*
|
|
162
|
+
* Emits are serialized through an internal promise chain so fire-and-forget
|
|
163
|
+
* calls (e.g. decision events surfaced from inside a tool handler) still
|
|
164
|
+
* append to the same file in emission order.
|
|
165
|
+
*/
|
|
166
|
+
export class EventFeed {
|
|
167
|
+
private queue: Promise<void> = Promise.resolve()
|
|
168
|
+
|
|
169
|
+
constructor(
|
|
170
|
+
private readonly workspaceRoot: string,
|
|
171
|
+
private readonly sessionId: string,
|
|
172
|
+
private readonly onError: (eventType: string, error: unknown) => void,
|
|
173
|
+
/**
|
|
174
|
+
* Recursive task decomposition (`new_task`): the parent session's id
|
|
175
|
+
* and this session's recursion depth, stamped onto EVERY record so a
|
|
176
|
+
* child feed is self-describing. Absent on root sessions (no-op).
|
|
177
|
+
*/
|
|
178
|
+
private readonly lineage?: { parentSessionId?: string; recursionDepth?: number },
|
|
179
|
+
) {}
|
|
180
|
+
|
|
181
|
+
/** Append a raw event record (non-fatal). */
|
|
182
|
+
emit(type: string, fields: Record<string, unknown> = {}): Promise<void> {
|
|
183
|
+
const record: EventRecord = {
|
|
184
|
+
ts: new Date().toISOString(),
|
|
185
|
+
sessionId: this.sessionId,
|
|
186
|
+
type,
|
|
187
|
+
...(this.lineage?.parentSessionId ? { parentSessionId: this.lineage.parentSessionId } : {}),
|
|
188
|
+
...(this.lineage?.recursionDepth !== undefined ? { recursionDepth: this.lineage.recursionDepth } : {}),
|
|
189
|
+
...fields,
|
|
190
|
+
}
|
|
191
|
+
const task = this.queue.then(() => appendEvent(this.workspaceRoot, this.sessionId, record))
|
|
192
|
+
// Keep the chain alive even when a write fails (the error is reported
|
|
193
|
+
// to onError; later events must still be attempted).
|
|
194
|
+
this.queue = task.catch(() => undefined)
|
|
195
|
+
return task
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
sessionStart(fields: { mode: string; model: string; workspaceRoot: string; taskText: string }): Promise<void> {
|
|
199
|
+
const task = truncateField(fields.taskText)
|
|
200
|
+
return this.emit("session_start", {
|
|
201
|
+
mode: fields.mode,
|
|
202
|
+
model: fields.model,
|
|
203
|
+
workspaceRoot: fields.workspaceRoot,
|
|
204
|
+
task: task.text,
|
|
205
|
+
...(task.truncated ? { taskTruncated: true } : {}),
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
iterationStart(iteration: number, historyMessageCount: number): Promise<void> {
|
|
210
|
+
return this.emit("iteration_start", { iteration, historyMessageCount })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
llmResponse(fields: {
|
|
214
|
+
iteration: number
|
|
215
|
+
hadToolCalls: boolean
|
|
216
|
+
textPreview?: string
|
|
217
|
+
/** Reasoning/"thinking" text (streaming-and-reasoning), truncated like other large fields. */
|
|
218
|
+
reasoningPreview?: string
|
|
219
|
+
inputTokens?: number
|
|
220
|
+
outputTokens?: number
|
|
221
|
+
cachedTokens?: number
|
|
222
|
+
}): Promise<void> {
|
|
223
|
+
const text = fields.textPreview ? truncateField(fields.textPreview) : undefined
|
|
224
|
+
const reasoning = fields.reasoningPreview ? truncateField(fields.reasoningPreview) : undefined
|
|
225
|
+
return this.emit("llm_response", {
|
|
226
|
+
iteration: fields.iteration,
|
|
227
|
+
hadToolCalls: fields.hadToolCalls,
|
|
228
|
+
...(text ? { textPreview: text.text, ...(text.truncated ? { textTruncated: true } : {}) } : {}),
|
|
229
|
+
...(reasoning
|
|
230
|
+
? { reasoningPreview: reasoning.text, ...(reasoning.truncated ? { reasoningTruncated: true } : {}) }
|
|
231
|
+
: {}),
|
|
232
|
+
...(fields.inputTokens !== undefined ? { inputTokens: fields.inputTokens } : {}),
|
|
233
|
+
...(fields.outputTokens !== undefined ? { outputTokens: fields.outputTokens } : {}),
|
|
234
|
+
...(fields.cachedTokens !== undefined ? { cachedTokens: fields.cachedTokens } : {}),
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One incremental chunk of a streamed LLM response (streaming-and-reasoning,
|
|
240
|
+
* opt-in). Distinct from the single `llm_response` summary event which still
|
|
241
|
+
* fires once at the end of the call with final totals — this is the
|
|
242
|
+
* live-typing feed. `kind` is "text" | "reasoning" | "tool" so the dashboard
|
|
243
|
+
* can render reasoning text differently from the answer text. Truncated like
|
|
244
|
+
* every other large field.
|
|
245
|
+
*/
|
|
246
|
+
llmStreamChunk(fields: {
|
|
247
|
+
iteration: number
|
|
248
|
+
kind: "text" | "reasoning" | "tool"
|
|
249
|
+
chunk: string
|
|
250
|
+
}): Promise<void> {
|
|
251
|
+
const c = truncateField(fields.chunk)
|
|
252
|
+
return this.emit("llm_stream_chunk", {
|
|
253
|
+
iteration: fields.iteration,
|
|
254
|
+
kind: fields.kind,
|
|
255
|
+
chunk: c.text,
|
|
256
|
+
...(c.truncated ? { chunkTruncated: true } : {}),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
toolCall(fields: { iteration: number; tool: string; args: string; argsTruncated: boolean }): Promise<void> {
|
|
261
|
+
return this.emit("tool_call", {
|
|
262
|
+
iteration: fields.iteration,
|
|
263
|
+
tool: fields.tool,
|
|
264
|
+
args: fields.args,
|
|
265
|
+
...(fields.argsTruncated ? { argsTruncated: true } : {}),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
toolResult(fields: {
|
|
270
|
+
iteration: number
|
|
271
|
+
tool: string
|
|
272
|
+
isError: boolean
|
|
273
|
+
result: string
|
|
274
|
+
resultTruncated: boolean
|
|
275
|
+
}): Promise<void> {
|
|
276
|
+
return this.emit("tool_result", {
|
|
277
|
+
iteration: fields.iteration,
|
|
278
|
+
tool: fields.tool,
|
|
279
|
+
isError: fields.isError,
|
|
280
|
+
result: fields.result,
|
|
281
|
+
...(fields.resultTruncated ? { resultTruncated: true } : {}),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The session's final report (issue #34). Emitted when the loop accepts
|
|
287
|
+
* completion — the one tool call that matters most for understanding a
|
|
288
|
+
* review/QA/worker verdict was previously invisible to the feed because
|
|
289
|
+
* attempt_completion short-circuits before the tool-execution loop (no
|
|
290
|
+
* tool_call/tool_result pair is ever recorded for it). `result` is the
|
|
291
|
+
* FULL report text, deliberately NOT truncated (the only event field that
|
|
292
|
+
* ignores EVENT_TRUNCATE_CHARS — see the module doc comment for why); the
|
|
293
|
+
* full report is also persisted to `.headlesscode/reports/<sessionId>.md`
|
|
294
|
+
* by the loop, so the feed and the file are two views of the same text.
|
|
295
|
+
*/
|
|
296
|
+
attemptCompletion(fields: { iteration: number; result: string }): Promise<void> {
|
|
297
|
+
return this.emit("attempt_completion", {
|
|
298
|
+
iteration: fields.iteration,
|
|
299
|
+
result: fields.result,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** iteration 0 = the session's baseline checkpoint (before iteration 1). */
|
|
304
|
+
checkpointSaved(iteration: number): Promise<void> {
|
|
305
|
+
return this.emit("checkpoint_saved", { iteration })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A context-condensation pass replaced the oldest turns with one synthetic
|
|
310
|
+
* summary message (see src/engine/condense.ts). Carries the iteration whose
|
|
311
|
+
* call triggered it, the message counts before/after, and the condensation
|
|
312
|
+
* call's own token usage when known (the dashboard timeline marks the
|
|
313
|
+
* point; the tokens make the spend visible — condensation was previously
|
|
314
|
+
* only a log line, invisible to the feed).
|
|
315
|
+
*/
|
|
316
|
+
condensed(fields: {
|
|
317
|
+
iteration: number
|
|
318
|
+
messagesBefore: number
|
|
319
|
+
messagesAfter: number
|
|
320
|
+
inputTokens?: number
|
|
321
|
+
outputTokens?: number
|
|
322
|
+
cachedTokens?: number
|
|
323
|
+
}): Promise<void> {
|
|
324
|
+
return this.emit("condensed", {
|
|
325
|
+
iteration: fields.iteration,
|
|
326
|
+
messagesBefore: fields.messagesBefore,
|
|
327
|
+
messagesAfter: fields.messagesAfter,
|
|
328
|
+
...(fields.inputTokens !== undefined ? { inputTokens: fields.inputTokens } : {}),
|
|
329
|
+
...(fields.outputTokens !== undefined ? { outputTokens: fields.outputTokens } : {}),
|
|
330
|
+
...(fields.cachedTokens !== undefined ? { cachedTokens: fields.cachedTokens } : {}),
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Todo-list state change (update_todo_list): the full normalized checklist
|
|
336
|
+
* (truncated like every other large field) plus done / in-progress /
|
|
337
|
+
* pending counts, so the dashboard can show live planning state and the
|
|
338
|
+
* history can show how the checklist evolved across the session.
|
|
339
|
+
*/
|
|
340
|
+
todoUpdated(fields: { todos: string; done: number; inProgress: number; pending: number }): Promise<void> {
|
|
341
|
+
const t = truncateField(fields.todos)
|
|
342
|
+
return this.emit("todo_updated", {
|
|
343
|
+
todos: t.text,
|
|
344
|
+
...(t.truncated ? { todosTruncated: true } : {}),
|
|
345
|
+
done: fields.done,
|
|
346
|
+
inProgress: fields.inProgress,
|
|
347
|
+
pending: fields.pending,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
decisionBlocked(question: string, suggestions?: string[]): Promise<void> {
|
|
352
|
+
const q = truncateField(question)
|
|
353
|
+
return this.emit("decision_blocked", {
|
|
354
|
+
question: q.text,
|
|
355
|
+
...(q.truncated ? { questionTruncated: true } : {}),
|
|
356
|
+
...(suggestions && suggestions.length > 0 ? { suggestions } : {}),
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Answer received, or the wait timed out (timedOut: true) and the worker moved on. */
|
|
361
|
+
decisionAnswered(answer?: string, timedOut = false): Promise<void> {
|
|
362
|
+
const a = answer !== undefined ? truncateField(answer) : undefined
|
|
363
|
+
return this.emit("decision_answered", {
|
|
364
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
365
|
+
...(a ? { answer: a.text, ...(a.truncated ? { answerTruncated: true } : {}) } : {}),
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* switch_mode changed the session's OWN active mode in place (same
|
|
371
|
+
* session/history/budget, new system prompt + tool set from this point
|
|
372
|
+
* forward — see plans/switch-mode-headless.md). autoApproved tells the
|
|
373
|
+
* dashboard whether the switch went through the human/orchestrator
|
|
374
|
+
* approval gate (false) or the off-by-default auto-approve opt-in (true).
|
|
375
|
+
*/
|
|
376
|
+
modeSwitched(fields: { from: string; to: string; reason: string; autoApproved: boolean }): Promise<void> {
|
|
377
|
+
const reason = truncateField(fields.reason)
|
|
378
|
+
return this.emit("mode_switched", {
|
|
379
|
+
from: fields.from,
|
|
380
|
+
to: fields.to,
|
|
381
|
+
reason: reason.text,
|
|
382
|
+
...(reason.truncated ? { reasonTruncated: true } : {}),
|
|
383
|
+
autoApproved: fields.autoApproved,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Mid-session message injection (live chat-UI control): a human wrote a
|
|
389
|
+
* new message into a RUNNING session (POST /api/session/:id/message →
|
|
390
|
+
* `.harness.inject-message` marker; see loop.ts's checkInjectedMessage).
|
|
391
|
+
* The text is a plain user-role message the session appended to its live
|
|
392
|
+
* history; the dashboard renders it inline as if the user had just typed
|
|
393
|
+
* it. Truncated like every other large field.
|
|
394
|
+
*/
|
|
395
|
+
messageInjected(fields: { text: string }): Promise<void> {
|
|
396
|
+
const t = truncateField(fields.text)
|
|
397
|
+
return this.emit("message_injected", {
|
|
398
|
+
text: t.text,
|
|
399
|
+
...(t.truncated ? { textTruncated: true } : {}),
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
paused(reason?: string): Promise<void> {
|
|
404
|
+
return this.emit("paused", { ...(reason ? { reason } : {}) })
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
resumed(reason?: string): Promise<void> {
|
|
408
|
+
return this.emit("resumed", { ...(reason ? { reason } : {}) })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
sessionEnd(fields: {
|
|
412
|
+
status: string
|
|
413
|
+
iterations: number
|
|
414
|
+
costUsd: number
|
|
415
|
+
inputTokens: number
|
|
416
|
+
outputTokens: number
|
|
417
|
+
cachedTokens: number
|
|
418
|
+
}): Promise<void> {
|
|
419
|
+
return this.emit("session_end", {
|
|
420
|
+
status: fields.status,
|
|
421
|
+
iterations: fields.iterations,
|
|
422
|
+
costUsd: fields.costUsd,
|
|
423
|
+
inputTokens: fields.inputTokens,
|
|
424
|
+
outputTokens: fields.outputTokens,
|
|
425
|
+
cachedTokens: fields.cachedTokens,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-restart handoff summary — `<workspaceRoot>/.headlesscode/handoff-summary.md`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the on-disk-layout modules idiom (src/engine/reports.ts for final
|
|
5
|
+
* reports, src/engine/usage.ts for usage): one module owns one layout.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: when a session hits the iteration cap, the orchestrator
|
|
8
|
+
* spawns a continuation worker on the SAME worktree, but as a BRAND NEW
|
|
9
|
+
* conversation — the previous session's entire message history (every
|
|
10
|
+
* `read_file`/`execute_command` result, every bit of reasoning about where
|
|
11
|
+
* things live) is gone. Only the continuation task file and whatever is on
|
|
12
|
+
* disk (git diff, commits) survive. In practice the continuation task file
|
|
13
|
+
* only says "go inspect the worktree yourself", so the new session re-derives
|
|
14
|
+
* everything the old one already learned at real cost — this was measured on
|
|
15
|
+
* a live job: a continuation session re-read the exact same files (in full)
|
|
16
|
+
* that the session before it had already read in full.
|
|
17
|
+
*
|
|
18
|
+
* The fix: right before a session gives up on the iteration cap, it
|
|
19
|
+
* summarizes its own history (via condense.ts's condenseOldestTurns — the
|
|
20
|
+
* same LLM-compression machinery the mid-session condensation path already
|
|
21
|
+
* uses) and writes the result here. The orchestrator's continuation-task-file
|
|
22
|
+
* builder reads it and inlines it as "what the previous session already
|
|
23
|
+
* learned", so the new session starts from a compact summary instead of a
|
|
24
|
+
* blank slate.
|
|
25
|
+
*
|
|
26
|
+
* A single FIXED filename (not per-session): the orchestrator only ever needs
|
|
27
|
+
* "the latest handoff for this worktree", and each continuation attempt
|
|
28
|
+
* refreshes it. `clearHandoffSummary` removes it once consumed, so a stale
|
|
29
|
+
* summary from a finished task never leaks into an unrelated later one.
|
|
30
|
+
*
|
|
31
|
+
* Like every other auxiliary write path in this codebase, writing is
|
|
32
|
+
* deliberately non-fatal: callers wrap it in try/catch and log a warning — a
|
|
33
|
+
* failed handoff write must never abort or corrupt the session that produced
|
|
34
|
+
* it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from "node:fs"
|
|
38
|
+
import * as fsp from "node:fs/promises"
|
|
39
|
+
import * as path from "node:path"
|
|
40
|
+
|
|
41
|
+
/** The handoff summary file for a workspace: `<workspaceRoot>/.headlesscode/handoff-summary.md`. */
|
|
42
|
+
export function handoffSummaryPath(workspaceRoot: string): string {
|
|
43
|
+
return path.join(workspaceRoot, ".headlesscode", "handoff-summary.md")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Persist a session's handoff summary, overwriting any previous one for this worktree. */
|
|
47
|
+
export async function writeHandoffSummary(workspaceRoot: string, summaryText: string): Promise<string> {
|
|
48
|
+
const file = handoffSummaryPath(workspaceRoot)
|
|
49
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
50
|
+
await fsp.writeFile(file, summaryText, "utf-8")
|
|
51
|
+
return file
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Synchronous read for the orchestrator's task-file builder (no existing async plumbing there). Returns undefined if absent/unreadable. */
|
|
55
|
+
export function readHandoffSummary(workspaceRoot: string): string | undefined {
|
|
56
|
+
const file = handoffSummaryPath(workspaceRoot)
|
|
57
|
+
try {
|
|
58
|
+
return fs.readFileSync(file, "utf-8")
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove a consumed handoff summary so it never leaks into a later, unrelated continuation. Non-fatal. */
|
|
65
|
+
export function clearHandoffSummary(workspaceRoot: string): void {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(handoffSummaryPath(workspaceRoot))
|
|
68
|
+
} catch {
|
|
69
|
+
// Absent or unremovable — nothing to clean up, nothing to report.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy tool-catalog loading — opt-in (default OFF), gated to sessions that
|
|
3
|
+
* need it: a small local model's context window can be dominated by the
|
|
4
|
+
* FULL tool catalog before the task even starts (measured live 2026-08-19
|
|
5
|
+
* against Qwen3-Coder-30B-A3B on an RTX 5080: 21 schemas = ~19.5K prompt
|
|
6
|
+
* tokens against a ~24.5K real ceiling — under 5K tokens left for the
|
|
7
|
+
* actual task). Cloud sessions (OpenRouter/DeepSeek) keep today's static
|
|
8
|
+
* full catalog untouched: a stable prefix is what makes their prompt cache
|
|
9
|
+
* cheap, and changing it per-call would hurt cache-hit rate for no local
|
|
10
|
+
* benefit. This module only ever narrows what ONE session sends; it never
|
|
11
|
+
* changes what a tool DOES.
|
|
12
|
+
*
|
|
13
|
+
* Same discovery-then-fetch shape published for MCP/tool-heavy agentic
|
|
14
|
+
* workflows (dynamic tool gating + lazy schema loading): the model gets a
|
|
15
|
+
* cheap index up front (`list_tools`) and pulls a tool's real schema into
|
|
16
|
+
* its OWN next turn only once it decides it needs it (`request_tool`),
|
|
17
|
+
* instead of paying for every schema on every turn regardless of use.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ChatTool } from "./types.js"
|
|
21
|
+
|
|
22
|
+
export const LAZY_TOOL_CATALOG_ENV = "HEADLESSCODE_LAZY_TOOL_CATALOG"
|
|
23
|
+
|
|
24
|
+
export function isLazyToolCatalogEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
25
|
+
const v = env[LAZY_TOOL_CATALOG_ENV]
|
|
26
|
+
return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tools sent on every turn regardless of lazy-loading: the minimum needed
|
|
31
|
+
* to read, edit, run commands, delegate, and finish a task. Everything else
|
|
32
|
+
* in a session's full tool list is offered lazily via `list_tools` /
|
|
33
|
+
* `request_tool` instead.
|
|
34
|
+
*
|
|
35
|
+
* `apply_diff` deliberately excluded (available lazily, not core) — its
|
|
36
|
+
* SEARCH/REPLACE block format requires escaping literal `=======`/`<<<<<<<`
|
|
37
|
+
* markers found in real file content, and a `:start_line:[line_number]`
|
|
38
|
+
* header where `[line_number]` is a template placeholder to substitute a
|
|
39
|
+
* real integer into. Verified live 2026-08-19 against Qwen2.5-Coder-14B:
|
|
40
|
+
* it copied `:start_line:[line_number]` into a real call verbatim (treating
|
|
41
|
+
* the placeholder as literal syntax) and separately failed to escape a
|
|
42
|
+
* literal `=======` divider inside a file's own content across 4 consecutive
|
|
43
|
+
* retries despite an explicit corrective error message each time. `edit_file`
|
|
44
|
+
* and `search_replace` need none of this — plain old_string/new_string with
|
|
45
|
+
* NO escaping rules and (edit_file) a fuzzy-match fallback — and stay core.
|
|
46
|
+
*/
|
|
47
|
+
export const CORE_TOOL_NAMES = new Set([
|
|
48
|
+
"ask_followup_question",
|
|
49
|
+
"attempt_completion",
|
|
50
|
+
"execute_command",
|
|
51
|
+
"list_files",
|
|
52
|
+
"new_task",
|
|
53
|
+
"read_file",
|
|
54
|
+
// search_replace deliberately excluded — same reasoning as apply_diff
|
|
55
|
+
// above it in git history: search_replace requires an EXACT literal
|
|
56
|
+
// match (see searchReplaceHandler, src/tools/executor.ts) with no
|
|
57
|
+
// fallback, while edit_file has a 3-stage fallback chain (exact →
|
|
58
|
+
// whitespace-tolerant → token-based) specifically built to survive the
|
|
59
|
+
// whitespace/indentation drift a model's old_string commonly has.
|
|
60
|
+
// Verified live 2026-08-20: given both tools, a local model picked
|
|
61
|
+
// search_replace, hit exactly the whitespace-mismatch failure edit_file
|
|
62
|
+
// exists to prevent, then spiralled into an unrelated list_files loop
|
|
63
|
+
// instead of recovering — a harness/tool-catalog problem, not
|
|
64
|
+
// necessarily a model capability ceiling. Still reachable via
|
|
65
|
+
// request_tool for a session that genuinely needs it.
|
|
66
|
+
"edit_file",
|
|
67
|
+
// set_indentation deliberately core, not lazy (issue #141): it exists
|
|
68
|
+
// specifically to give local sessions — the same sessions lazy-loading
|
|
69
|
+
// targets — a way to fix a pure indentation mismatch without edit_file's
|
|
70
|
+
// two-near-identical-multi-line-strings shape. Live-verified 2026-08-21:
|
|
71
|
+
// gating it behind request_tool defeated the whole point — a model given
|
|
72
|
+
// an explicit task-level instruction to use it still defaulted back to
|
|
73
|
+
// (always-visible) edit_file 2/2 times, then on a 3rd trial DID try to
|
|
74
|
+
// call it correctly but only as narrated text, never having pulled its
|
|
75
|
+
// real schema in via request_tool first.
|
|
76
|
+
"set_indentation",
|
|
77
|
+
"switch_mode",
|
|
78
|
+
"update_todo_list",
|
|
79
|
+
"write_to_file",
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
export const LIST_TOOLS_NAME = "list_tools"
|
|
83
|
+
export const REQUEST_TOOL_NAME = "request_tool"
|
|
84
|
+
|
|
85
|
+
export interface SplitTools {
|
|
86
|
+
/** Always sent: the core set plus list_tools/request_tool themselves. */
|
|
87
|
+
core: ChatTool[]
|
|
88
|
+
/** Available on request only, keyed by tool name. */
|
|
89
|
+
lazyByName: Map<string, ChatTool>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Split a session's full (already executor-gated) tool list into core + lazy. */
|
|
93
|
+
export function splitCoreAndLazyTools(allTools: ChatTool[]): SplitTools {
|
|
94
|
+
const core: ChatTool[] = []
|
|
95
|
+
const lazyByName = new Map<string, ChatTool>()
|
|
96
|
+
for (const tool of allTools) {
|
|
97
|
+
if (tool.type !== "function") {
|
|
98
|
+
core.push(tool)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (CORE_TOOL_NAMES.has(tool.function.name)) {
|
|
102
|
+
core.push(tool)
|
|
103
|
+
} else {
|
|
104
|
+
lazyByName.set(tool.function.name, tool)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
core: [...core, buildListToolsTool(), buildRequestToolTool()],
|
|
109
|
+
lazyByName,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildListToolsTool(): ChatTool {
|
|
114
|
+
return {
|
|
115
|
+
type: "function",
|
|
116
|
+
function: {
|
|
117
|
+
name: LIST_TOOLS_NAME,
|
|
118
|
+
description:
|
|
119
|
+
"List additional tools available in this session beyond the core set " +
|
|
120
|
+
"(read_file, write_to_file, apply_diff, search_replace, edit_file, " +
|
|
121
|
+
"execute_command, list_files, attempt_completion, ask_followup_question, " +
|
|
122
|
+
"switch_mode, new_task, update_todo_list — always available, not listed " +
|
|
123
|
+
"here). Call request_tool with a name from this list to make that tool " +
|
|
124
|
+
"callable on your NEXT turn.",
|
|
125
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function buildRequestToolTool(): ChatTool {
|
|
131
|
+
return {
|
|
132
|
+
type: "function",
|
|
133
|
+
function: {
|
|
134
|
+
name: REQUEST_TOOL_NAME,
|
|
135
|
+
description:
|
|
136
|
+
"Make one additional tool (from list_tools' output) callable starting " +
|
|
137
|
+
"on your NEXT turn. Costs nothing to call again if already active.",
|
|
138
|
+
parameters: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: { name: { type: "string", description: "Exact tool name from list_tools" } },
|
|
141
|
+
required: ["name"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One line per lazy tool: name + its schema's own description, truncated. */
|
|
148
|
+
export function renderToolIndex(lazyByName: Map<string, ChatTool>): string {
|
|
149
|
+
if (lazyByName.size === 0) {
|
|
150
|
+
return "No additional tools are available in this session."
|
|
151
|
+
}
|
|
152
|
+
const lines = [...lazyByName.values()].map((tool) => {
|
|
153
|
+
if (tool.type !== "function") {
|
|
154
|
+
return `- ${tool.type}`
|
|
155
|
+
}
|
|
156
|
+
const description = (tool.function.description ?? "").split(/(?<=[.!?])\s/)[0] ?? ""
|
|
157
|
+
return `- ${tool.function.name}: ${description.slice(0, 120)}`
|
|
158
|
+
})
|
|
159
|
+
return lines.join("\n")
|
|
160
|
+
}
|