reasonix 0.5.23 → 0.6.0
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/dist/cli/{chunk-ANMDY236.js → chunk-NXYPGKA3.js} +89 -23
- package/dist/cli/chunk-NXYPGKA3.js.map +1 -0
- package/dist/cli/index.js +3731 -1826
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/{prompt-75XLIUTO.js → prompt-KX6A4DVX.js} +2 -2
- package/dist/index.d.ts +304 -1
- package/dist/index.js +1021 -110
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/cli/chunk-ANMDY236.js.map +0 -1
- /package/dist/cli/{prompt-75XLIUTO.js.map → prompt-KX6A4DVX.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -47,8 +47,8 @@ function computeWait(attempt, initial, cap, retryAfter) {
|
|
|
47
47
|
}
|
|
48
48
|
function sleep(ms, signal) {
|
|
49
49
|
if (ms <= 0) return Promise.resolve();
|
|
50
|
-
return new Promise((
|
|
51
|
-
const timer = setTimeout(
|
|
50
|
+
return new Promise((resolve9, reject) => {
|
|
51
|
+
const timer = setTimeout(resolve9, ms);
|
|
52
52
|
if (signal) {
|
|
53
53
|
const onAbort = () => {
|
|
54
54
|
clearTimeout(timer);
|
|
@@ -533,7 +533,7 @@ function matchesTool(hook, toolName) {
|
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
535
|
function defaultSpawner(input) {
|
|
536
|
-
return new Promise((
|
|
536
|
+
return new Promise((resolve9) => {
|
|
537
537
|
const child = spawn(input.command, {
|
|
538
538
|
cwd: input.cwd,
|
|
539
539
|
shell: true,
|
|
@@ -560,7 +560,7 @@ function defaultSpawner(input) {
|
|
|
560
560
|
});
|
|
561
561
|
child.once("error", (err) => {
|
|
562
562
|
clearTimeout(timer);
|
|
563
|
-
|
|
563
|
+
resolve9({
|
|
564
564
|
exitCode: null,
|
|
565
565
|
stdout,
|
|
566
566
|
stderr,
|
|
@@ -570,7 +570,7 @@ function defaultSpawner(input) {
|
|
|
570
570
|
});
|
|
571
571
|
child.once("close", (code) => {
|
|
572
572
|
clearTimeout(timer);
|
|
573
|
-
|
|
573
|
+
resolve9({
|
|
574
574
|
exitCode: code,
|
|
575
575
|
stdout: stdout.trim(),
|
|
576
576
|
stderr: stderr.trim(),
|
|
@@ -900,6 +900,12 @@ var ToolRegistry = class {
|
|
|
900
900
|
* bounced until the user approves a submitted plan.
|
|
901
901
|
*/
|
|
902
902
|
_planMode = false;
|
|
903
|
+
/**
|
|
904
|
+
* Optional hook run after arg parsing but before tool.fn. Lets the TUI
|
|
905
|
+
* reroute specific tool calls (e.g. edit_file in review mode) without
|
|
906
|
+
* modifying the tool definitions themselves.
|
|
907
|
+
*/
|
|
908
|
+
_interceptor = null;
|
|
903
909
|
constructor(opts = {}) {
|
|
904
910
|
this._autoFlatten = opts.autoFlatten !== false;
|
|
905
911
|
}
|
|
@@ -911,6 +917,14 @@ var ToolRegistry = class {
|
|
|
911
917
|
get planMode() {
|
|
912
918
|
return this._planMode;
|
|
913
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* Install or clear the dispatch interceptor. At most one interceptor
|
|
922
|
+
* is active at a time — calling twice replaces the previous. Pass
|
|
923
|
+
* `null` to remove.
|
|
924
|
+
*/
|
|
925
|
+
setToolInterceptor(fn) {
|
|
926
|
+
this._interceptor = fn;
|
|
927
|
+
}
|
|
914
928
|
register(def) {
|
|
915
929
|
if (!def.name) throw new Error("tool requires a name");
|
|
916
930
|
const internal = { ...def };
|
|
@@ -967,6 +981,16 @@ var ToolRegistry = class {
|
|
|
967
981
|
error: `${name}: unavailable in plan mode \u2014 this is a read-only exploration phase. Use read_file / list_directory / search_files / directory_tree / web_search / allowlisted shell commands to investigate. Call submit_plan with your proposed plan when you're ready for the user's review.`
|
|
968
982
|
});
|
|
969
983
|
}
|
|
984
|
+
if (this._interceptor) {
|
|
985
|
+
try {
|
|
986
|
+
const short = await this._interceptor(name, args);
|
|
987
|
+
if (typeof short === "string") return short;
|
|
988
|
+
} catch (err) {
|
|
989
|
+
return JSON.stringify({
|
|
990
|
+
error: `${name}: interceptor failed \u2014 ${err.message}`
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
970
994
|
try {
|
|
971
995
|
const result = await tool.fn(args, { signal: opts.signal });
|
|
972
996
|
const str = typeof result === "string" ? result : JSON.stringify(result);
|
|
@@ -1690,7 +1714,8 @@ var SessionStats = class {
|
|
|
1690
1714
|
claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
|
|
1691
1715
|
savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
|
|
1692
1716
|
cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
|
|
1693
|
-
lastPromptTokens: last?.usage.promptTokens ?? 0
|
|
1717
|
+
lastPromptTokens: last?.usage.promptTokens ?? 0,
|
|
1718
|
+
lastTurnCostUsd: round(last?.cost ?? 0, 6)
|
|
1694
1719
|
};
|
|
1695
1720
|
}
|
|
1696
1721
|
};
|
|
@@ -1700,6 +1725,12 @@ function round(n, digits) {
|
|
|
1700
1725
|
}
|
|
1701
1726
|
|
|
1702
1727
|
// src/loop.ts
|
|
1728
|
+
var ARGS_COMPACT_THRESHOLD_TOKENS = 800;
|
|
1729
|
+
var TURN_END_RESULT_CAP_TOKENS = 3e3;
|
|
1730
|
+
var FAILURE_ESCALATION_THRESHOLD = 3;
|
|
1731
|
+
var ESCALATION_MODEL = "deepseek-v4-pro";
|
|
1732
|
+
var NEEDS_PRO_MARKER = "<<<NEEDS_PRO>>>";
|
|
1733
|
+
var NEEDS_PRO_BUFFER_CHARS = 80;
|
|
1703
1734
|
var CacheFirstLoop = class {
|
|
1704
1735
|
client;
|
|
1705
1736
|
prefix;
|
|
@@ -1740,11 +1771,36 @@ var CacheFirstLoop = class {
|
|
|
1740
1771
|
* `step()` (the prior turn's signal has already fired).
|
|
1741
1772
|
*/
|
|
1742
1773
|
_turnAbort = new AbortController();
|
|
1774
|
+
/**
|
|
1775
|
+
* "Next turn should run on pro, regardless of this.model." Set by the
|
|
1776
|
+
* `/pro` slash command; consumed at the next turn's start (flipping
|
|
1777
|
+
* `_escalateThisTurn` on and self-clearing) so it's a fire-and-forget
|
|
1778
|
+
* single-turn upgrade. Survives across multiple slash inputs so
|
|
1779
|
+
* typing `/pro` and then hesitating a while before submitting a real
|
|
1780
|
+
* message still applies.
|
|
1781
|
+
*/
|
|
1782
|
+
_proArmedForNextTurn = false;
|
|
1783
|
+
/**
|
|
1784
|
+
* Active for the current turn only — true means every model call
|
|
1785
|
+
* this turn uses pro instead of `this.model`. Turned on by EITHER
|
|
1786
|
+
* the pro-armed consumption OR the mid-turn auto-escalation
|
|
1787
|
+
* threshold (see `_turnFailureCount`). Cleared at turn end.
|
|
1788
|
+
*/
|
|
1789
|
+
_escalateThisTurn = false;
|
|
1790
|
+
/**
|
|
1791
|
+
* Visible-failure count for the current turn. Incremented by tool
|
|
1792
|
+
* dispatch paths when a result matches a known "flash is struggling"
|
|
1793
|
+
* shape (SEARCH-not-found errors, scavenge / truncation / storm
|
|
1794
|
+
* repair fires). Once it hits {@link FAILURE_ESCALATION_THRESHOLD},
|
|
1795
|
+
* the remainder of the turn's model calls auto-upgrade to pro so
|
|
1796
|
+
* the user doesn't watch flash retry the same edit 5 times.
|
|
1797
|
+
*/
|
|
1798
|
+
_turnFailureCount = 0;
|
|
1743
1799
|
constructor(opts) {
|
|
1744
1800
|
this.client = opts.client;
|
|
1745
1801
|
this.prefix = opts.prefix;
|
|
1746
1802
|
this.tools = opts.tools ?? new ToolRegistry();
|
|
1747
|
-
this.model = opts.model ?? "deepseek-v4-
|
|
1803
|
+
this.model = opts.model ?? "deepseek-v4-flash";
|
|
1748
1804
|
this.reasoningEffort = opts.reasoningEffort ?? "max";
|
|
1749
1805
|
this.maxToolIters = opts.maxToolIters ?? 64;
|
|
1750
1806
|
this.hooks = opts.hooks ?? [];
|
|
@@ -1803,12 +1859,93 @@ var CacheFirstLoop = class {
|
|
|
1803
1859
|
* authored intent we can't mechanically shrink without losing
|
|
1804
1860
|
* meaning.
|
|
1805
1861
|
*/
|
|
1806
|
-
|
|
1862
|
+
/**
|
|
1863
|
+
* Conservative args-only shrink fired after every tool response —
|
|
1864
|
+
* strictly about ONE thing: stop oversized `edit_file` / `write_file`
|
|
1865
|
+
* arguments from riding every future turn's prompt.
|
|
1866
|
+
*
|
|
1867
|
+
* Why this is worth doing AUTOMATICALLY (not just on /compact):
|
|
1868
|
+
* Each tool-call arguments string sticks in the log verbatim. On a
|
|
1869
|
+
* coding session with ~10 edits, that's 20-40K tokens of stale
|
|
1870
|
+
* SEARCH/REPLACE text riding along on every turn. Even at a 98.9%
|
|
1871
|
+
* cache hit rate the input cost still adds up linearly (cache-hit
|
|
1872
|
+
* price × tokens × turns). Compacting IMMEDIATELY after the tool
|
|
1873
|
+
* responds means the next turn's prompt is already smaller — the
|
|
1874
|
+
* shrink is a one-time write that saves every future prompt.
|
|
1875
|
+
*
|
|
1876
|
+
* Threshold rationale: 800 tokens ≈ 3 KB. A typical 20-line edit's
|
|
1877
|
+
* args land well under that; massive rewrites (whole-file content,
|
|
1878
|
+
* 100+ line refactors) land above and get the compaction. Small
|
|
1879
|
+
* edits stay byte-verbatim so nothing common-case changes.
|
|
1880
|
+
*
|
|
1881
|
+
* Safety: we ONLY shrink args whose tool has ALREADY responded.
|
|
1882
|
+
* Structurally that's every call in `log.toMessages()` at this
|
|
1883
|
+
* point — the current turn's assistant/tool pairing is by
|
|
1884
|
+
* construction closed by the time we get here (append happens
|
|
1885
|
+
* AFTER dispatch). The in-flight assistant message being built
|
|
1886
|
+
* lives in scratch, not the log, so this pass can't touch it.
|
|
1887
|
+
*
|
|
1888
|
+
* Model impact: the model may occasionally want to reference the
|
|
1889
|
+
* exact SEARCH text of a prior edit — it then reads the file
|
|
1890
|
+
* directly (which shows current state) or looks at the preceding
|
|
1891
|
+
* assistant text (which has its plan). Losing the stale args is a
|
|
1892
|
+
* net win: one extra read_file vs. dragging N KB of stale text
|
|
1893
|
+
* through every subsequent turn.
|
|
1894
|
+
*/
|
|
1895
|
+
compactToolCallArgsAfterResponse() {
|
|
1807
1896
|
const before = this.log.toMessages();
|
|
1808
|
-
const { messages, healedCount
|
|
1897
|
+
const { messages, healedCount } = shrinkOversizedToolCallArgsByTokens(
|
|
1809
1898
|
before,
|
|
1810
|
-
|
|
1899
|
+
ARGS_COMPACT_THRESHOLD_TOKENS
|
|
1811
1900
|
);
|
|
1901
|
+
if (healedCount === 0) return;
|
|
1902
|
+
this.log.compactInPlace(messages);
|
|
1903
|
+
if (this.sessionName) {
|
|
1904
|
+
try {
|
|
1905
|
+
rewriteSession(this.sessionName, messages);
|
|
1906
|
+
} catch {
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Fired at the END of a turn (just before `done` is yielded). Shrinks
|
|
1912
|
+
* every tool RESULT in the log that exceeds {@link TURN_END_RESULT_CAP_TOKENS}
|
|
1913
|
+
* to a tight cap so the NEXT turn's prompt doesn't re-pay for big
|
|
1914
|
+
* reads or searches done earlier. Unlike the reactive 40/80%
|
|
1915
|
+
* thresholds which react to context pressure, this runs unconditionally
|
|
1916
|
+
* — the win is preventive: each turn's big outputs get trimmed before
|
|
1917
|
+
* they ride into the next prompt. Saves compounding cost on long
|
|
1918
|
+
* sessions.
|
|
1919
|
+
*
|
|
1920
|
+
* Why compact the JUST-finished turn's results too (not just older
|
|
1921
|
+
* turns)? The same-turn iters already consumed the raw content to
|
|
1922
|
+
* make their decisions — the log is only carried forward for future
|
|
1923
|
+
* prompts. And "let me re-read the file" is vastly cheaper than
|
|
1924
|
+
* "carry this 12KB result in every future turn's prompt forever."
|
|
1925
|
+
*
|
|
1926
|
+
* Safe by construction: args-compact for THIS turn already ran
|
|
1927
|
+
* inside `compactToolCallArgsAfterResponse`; this pass is orthogonal.
|
|
1928
|
+
*/
|
|
1929
|
+
autoCompactToolResultsOnTurnEnd() {
|
|
1930
|
+
const before = this.log.toMessages();
|
|
1931
|
+
const shrunk = shrinkOversizedToolResultsByTokens(before, TURN_END_RESULT_CAP_TOKENS);
|
|
1932
|
+
if (shrunk.healedCount === 0) return;
|
|
1933
|
+
this.log.compactInPlace(shrunk.messages);
|
|
1934
|
+
if (this.sessionName) {
|
|
1935
|
+
try {
|
|
1936
|
+
rewriteSession(this.sessionName, shrunk.messages);
|
|
1937
|
+
} catch {
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
compact(maxTokens = 4e3) {
|
|
1942
|
+
const before = this.log.toMessages();
|
|
1943
|
+
const resultsPass = shrinkOversizedToolResultsByTokens(before, maxTokens);
|
|
1944
|
+
const argsPass = shrinkOversizedToolCallArgsByTokens(resultsPass.messages, maxTokens);
|
|
1945
|
+
const messages = argsPass.messages;
|
|
1946
|
+
const healedCount = resultsPass.healedCount + argsPass.healedCount;
|
|
1947
|
+
const tokensSaved = resultsPass.tokensSaved + argsPass.tokensSaved;
|
|
1948
|
+
const charsSaved = resultsPass.charsSaved + argsPass.charsSaved;
|
|
1812
1949
|
if (healedCount > 0) {
|
|
1813
1950
|
this.log.compactInPlace(messages);
|
|
1814
1951
|
if (this.sessionName) {
|
|
@@ -1883,6 +2020,78 @@ var CacheFirstLoop = class {
|
|
|
1883
2020
|
}
|
|
1884
2021
|
this.stream = this.branchEnabled ? false : this._streamPreference;
|
|
1885
2022
|
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Arm pro for the next turn (consumed at turn start). Called by
|
|
2025
|
+
* `/pro`. Idempotent — repeated calls stay armed, `disarmPro()`
|
|
2026
|
+
* clears. Separate from `/preset max` which persistently switches
|
|
2027
|
+
* this.model; armed state is strictly single-turn.
|
|
2028
|
+
*/
|
|
2029
|
+
armProForNextTurn() {
|
|
2030
|
+
this._proArmedForNextTurn = true;
|
|
2031
|
+
}
|
|
2032
|
+
/** Cancel `/pro` arming before the next turn starts. */
|
|
2033
|
+
disarmPro() {
|
|
2034
|
+
this._proArmedForNextTurn = false;
|
|
2035
|
+
}
|
|
2036
|
+
/** UI surface — true while `/pro` is queued but hasn't fired yet. */
|
|
2037
|
+
get proArmed() {
|
|
2038
|
+
return this._proArmedForNextTurn;
|
|
2039
|
+
}
|
|
2040
|
+
/** UI surface — true while the current turn is running on pro (armed or auto-escalated). */
|
|
2041
|
+
get escalatedThisTurn() {
|
|
2042
|
+
return this._escalateThisTurn;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Model the current model call should use. Defaults to `this.model`;
|
|
2046
|
+
* upgrades to {@link ESCALATION_MODEL} when the turn is armed for
|
|
2047
|
+
* pro (via `/pro`) or has hit the failure-escalation threshold.
|
|
2048
|
+
* Same thinking + effort policy applies regardless — pro defaults
|
|
2049
|
+
* to thinking=enabled and effort=max, which the current turn wanted
|
|
2050
|
+
* anyway when flash was struggling.
|
|
2051
|
+
*/
|
|
2052
|
+
modelForCurrentCall() {
|
|
2053
|
+
return this._escalateThisTurn ? ESCALATION_MODEL : this.model;
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* True when the assistant's content is a self-reported escalation
|
|
2057
|
+
* request. Only the FIRST line matters — the model is instructed
|
|
2058
|
+
* to emit the marker as the first output token if at all. Matching
|
|
2059
|
+
* anywhere else in the text is a normal content reference (e.g.
|
|
2060
|
+
* the user asked about the marker itself, or prose that happens
|
|
2061
|
+
* to contain angle-brackets).
|
|
2062
|
+
*/
|
|
2063
|
+
isEscalationRequest(content) {
|
|
2064
|
+
return content.trimStart().startsWith(NEEDS_PRO_MARKER);
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Check whether a tool result string looks like a "flash struggled"
|
|
2068
|
+
* signal and, if so, increment the turn's failure counter. Escalates
|
|
2069
|
+
* the REST of the current turn to pro once the threshold is hit.
|
|
2070
|
+
* Idempotent after escalation — further failures don't re-escalate,
|
|
2071
|
+
* but the turn is already on pro so it doesn't matter.
|
|
2072
|
+
*
|
|
2073
|
+
* Return: `true` when this call tipped the turn into escalation
|
|
2074
|
+
* mode (so the loop can surface a one-time warning to the user).
|
|
2075
|
+
*/
|
|
2076
|
+
noteToolFailureSignal(resultJson, repair) {
|
|
2077
|
+
let bumped = false;
|
|
2078
|
+
if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
|
|
2079
|
+
this._turnFailureCount += 1;
|
|
2080
|
+
bumped = true;
|
|
2081
|
+
}
|
|
2082
|
+
if (repair) {
|
|
2083
|
+
const repairs = repair.scavenged + repair.truncationsFixed + repair.stormsBroken;
|
|
2084
|
+
if (repairs > 0) {
|
|
2085
|
+
this._turnFailureCount += repairs;
|
|
2086
|
+
bumped = true;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
if (bumped && !this._escalateThisTurn && this._turnFailureCount >= FAILURE_ESCALATION_THRESHOLD) {
|
|
2090
|
+
this._escalateThisTurn = true;
|
|
2091
|
+
return true;
|
|
2092
|
+
}
|
|
2093
|
+
return false;
|
|
2094
|
+
}
|
|
1886
2095
|
buildMessages(pendingUser) {
|
|
1887
2096
|
const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
|
|
1888
2097
|
const msgs = [...this.prefix.toMessages(), ...healed.messages];
|
|
@@ -1937,8 +2146,23 @@ var CacheFirstLoop = class {
|
|
|
1937
2146
|
this._turn++;
|
|
1938
2147
|
this.scratch.reset();
|
|
1939
2148
|
this.repair.resetStorm();
|
|
2149
|
+
this._turnFailureCount = 0;
|
|
2150
|
+
this._escalateThisTurn = false;
|
|
2151
|
+
let armedConsumed = false;
|
|
2152
|
+
if (this._proArmedForNextTurn) {
|
|
2153
|
+
this._escalateThisTurn = true;
|
|
2154
|
+
this._proArmedForNextTurn = false;
|
|
2155
|
+
armedConsumed = true;
|
|
2156
|
+
}
|
|
1940
2157
|
this._turnAbort = new AbortController();
|
|
1941
2158
|
const signal = this._turnAbort.signal;
|
|
2159
|
+
if (armedConsumed) {
|
|
2160
|
+
yield {
|
|
2161
|
+
turn: this._turn,
|
|
2162
|
+
role: "warning",
|
|
2163
|
+
content: "\u21E7 /pro armed \u2014 this turn runs on deepseek-v4-pro (one-shot \xB7 disarms after turn)"
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
1942
2166
|
let pendingUser = userInput;
|
|
1943
2167
|
const toolSpecs = this.prefix.tools();
|
|
1944
2168
|
const warnAt = Math.max(1, Math.floor(this.maxToolIters * 0.7));
|
|
@@ -1958,6 +2182,7 @@ var CacheFirstLoop = class {
|
|
|
1958
2182
|
content: stoppedMsg,
|
|
1959
2183
|
forcedSummary: true
|
|
1960
2184
|
};
|
|
2185
|
+
this.autoCompactToolResultsOnTurnEnd();
|
|
1961
2186
|
yield { turn: this._turn, role: "done", content: stoppedMsg };
|
|
1962
2187
|
return;
|
|
1963
2188
|
}
|
|
@@ -2034,14 +2259,15 @@ var CacheFirstLoop = class {
|
|
|
2034
2259
|
queue.push(sample);
|
|
2035
2260
|
}
|
|
2036
2261
|
};
|
|
2262
|
+
const callModel = this.modelForCurrentCall();
|
|
2037
2263
|
const branchPromise = runBranches(
|
|
2038
2264
|
this.client,
|
|
2039
2265
|
{
|
|
2040
|
-
model:
|
|
2266
|
+
model: callModel,
|
|
2041
2267
|
messages,
|
|
2042
2268
|
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
2043
2269
|
signal,
|
|
2044
|
-
thinking: thinkingModeForModel(
|
|
2270
|
+
thinking: thinkingModeForModel(callModel),
|
|
2045
2271
|
reasoningEffort: this.reasoningEffort
|
|
2046
2272
|
},
|
|
2047
2273
|
{
|
|
@@ -2051,8 +2277,8 @@ var CacheFirstLoop = class {
|
|
|
2051
2277
|
}
|
|
2052
2278
|
);
|
|
2053
2279
|
for (let k = 0; k < budget; k++) {
|
|
2054
|
-
const sample = queue.shift() ?? await new Promise((
|
|
2055
|
-
waiter =
|
|
2280
|
+
const sample = queue.shift() ?? await new Promise((resolve9) => {
|
|
2281
|
+
waiter = resolve9;
|
|
2056
2282
|
});
|
|
2057
2283
|
yield {
|
|
2058
2284
|
turn: this._turn,
|
|
@@ -2090,21 +2316,41 @@ var CacheFirstLoop = class {
|
|
|
2090
2316
|
} else if (this.stream) {
|
|
2091
2317
|
const callBuf = /* @__PURE__ */ new Map();
|
|
2092
2318
|
const readyIndices = /* @__PURE__ */ new Set();
|
|
2319
|
+
const callModel = this.modelForCurrentCall();
|
|
2320
|
+
const bufferForEscalation = callModel !== ESCALATION_MODEL;
|
|
2321
|
+
let escalationBuf = "";
|
|
2322
|
+
let escalationBufFlushed = false;
|
|
2093
2323
|
for await (const chunk of this.client.stream({
|
|
2094
|
-
model:
|
|
2324
|
+
model: callModel,
|
|
2095
2325
|
messages,
|
|
2096
2326
|
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
2097
2327
|
signal,
|
|
2098
|
-
thinking: thinkingModeForModel(
|
|
2328
|
+
thinking: thinkingModeForModel(callModel),
|
|
2099
2329
|
reasoningEffort: this.reasoningEffort
|
|
2100
2330
|
})) {
|
|
2101
2331
|
if (chunk.contentDelta) {
|
|
2102
2332
|
assistantContent += chunk.contentDelta;
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2333
|
+
if (bufferForEscalation && !escalationBufFlushed) {
|
|
2334
|
+
escalationBuf += chunk.contentDelta;
|
|
2335
|
+
if (this.isEscalationRequest(escalationBuf)) {
|
|
2336
|
+
break;
|
|
2337
|
+
}
|
|
2338
|
+
if (escalationBuf.length >= NEEDS_PRO_BUFFER_CHARS || escalationBuf.includes("\n")) {
|
|
2339
|
+
escalationBufFlushed = true;
|
|
2340
|
+
yield {
|
|
2341
|
+
turn: this._turn,
|
|
2342
|
+
role: "assistant_delta",
|
|
2343
|
+
content: escalationBuf
|
|
2344
|
+
};
|
|
2345
|
+
escalationBuf = "";
|
|
2346
|
+
}
|
|
2347
|
+
} else {
|
|
2348
|
+
yield {
|
|
2349
|
+
turn: this._turn,
|
|
2350
|
+
role: "assistant_delta",
|
|
2351
|
+
content: chunk.contentDelta
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2108
2354
|
}
|
|
2109
2355
|
if (chunk.reasoningDelta) {
|
|
2110
2356
|
reasoningContent += chunk.reasoningDelta;
|
|
@@ -2145,13 +2391,23 @@ var CacheFirstLoop = class {
|
|
|
2145
2391
|
if (chunk.usage) usage = chunk.usage;
|
|
2146
2392
|
}
|
|
2147
2393
|
toolCalls = [...callBuf.values()];
|
|
2394
|
+
if (bufferForEscalation && !escalationBufFlushed && escalationBuf.length > 0) {
|
|
2395
|
+
if (!this.isEscalationRequest(escalationBuf)) {
|
|
2396
|
+
yield {
|
|
2397
|
+
turn: this._turn,
|
|
2398
|
+
role: "assistant_delta",
|
|
2399
|
+
content: escalationBuf
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2148
2403
|
} else {
|
|
2404
|
+
const callModel = this.modelForCurrentCall();
|
|
2149
2405
|
const resp = await this.client.chat({
|
|
2150
|
-
model:
|
|
2406
|
+
model: callModel,
|
|
2151
2407
|
messages,
|
|
2152
2408
|
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
2153
2409
|
signal,
|
|
2154
|
-
thinking: thinkingModeForModel(
|
|
2410
|
+
thinking: thinkingModeForModel(callModel),
|
|
2155
2411
|
reasoningEffort: this.reasoningEffort
|
|
2156
2412
|
});
|
|
2157
2413
|
assistantContent = resp.content;
|
|
@@ -2161,6 +2417,7 @@ var CacheFirstLoop = class {
|
|
|
2161
2417
|
}
|
|
2162
2418
|
} catch (err) {
|
|
2163
2419
|
if (signal.aborted) {
|
|
2420
|
+
this.autoCompactToolResultsOnTurnEnd();
|
|
2164
2421
|
yield { turn: this._turn, role: "done", content: "" };
|
|
2165
2422
|
return;
|
|
2166
2423
|
}
|
|
@@ -2172,7 +2429,27 @@ var CacheFirstLoop = class {
|
|
|
2172
2429
|
};
|
|
2173
2430
|
return;
|
|
2174
2431
|
}
|
|
2175
|
-
|
|
2432
|
+
if (this.modelForCurrentCall() !== ESCALATION_MODEL && this.isEscalationRequest(assistantContent)) {
|
|
2433
|
+
this._escalateThisTurn = true;
|
|
2434
|
+
yield {
|
|
2435
|
+
turn: this._turn,
|
|
2436
|
+
role: "warning",
|
|
2437
|
+
content: `\u21E7 flash requested escalation \u2014 retrying this turn on ${ESCALATION_MODEL}`
|
|
2438
|
+
};
|
|
2439
|
+
assistantContent = "";
|
|
2440
|
+
reasoningContent = "";
|
|
2441
|
+
toolCalls = [];
|
|
2442
|
+
usage = null;
|
|
2443
|
+
branchSummary = void 0;
|
|
2444
|
+
preHarvestedPlanState = void 0;
|
|
2445
|
+
iter--;
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
const turnStats = this.stats.record(
|
|
2449
|
+
this._turn,
|
|
2450
|
+
this.modelForCurrentCall(),
|
|
2451
|
+
usage ?? new Usage()
|
|
2452
|
+
);
|
|
2176
2453
|
if (pendingUser !== null) {
|
|
2177
2454
|
this.appendAndPersist({ role: "user", content: pendingUser });
|
|
2178
2455
|
pendingUser = null;
|
|
@@ -2203,6 +2480,13 @@ var CacheFirstLoop = class {
|
|
|
2203
2480
|
repair: report,
|
|
2204
2481
|
branch: branchSummary
|
|
2205
2482
|
};
|
|
2483
|
+
if (this.noteToolFailureSignal("", report)) {
|
|
2484
|
+
yield {
|
|
2485
|
+
turn: this._turn,
|
|
2486
|
+
role: "warning",
|
|
2487
|
+
content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailureCount} repair/error signals. Next turn falls back to ${this.model} unless /pro is armed.`
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2206
2490
|
if (report.stormsBroken > 0) {
|
|
2207
2491
|
const noteTail = report.notes.length ? ` \u2014 ${report.notes[report.notes.length - 1]}` : "";
|
|
2208
2492
|
const allSuppressed = repairedCalls.length === 0 && toolCalls.length > 0;
|
|
@@ -2214,13 +2498,14 @@ var CacheFirstLoop = class {
|
|
|
2214
2498
|
};
|
|
2215
2499
|
}
|
|
2216
2500
|
if (repairedCalls.length === 0) {
|
|
2501
|
+
this.autoCompactToolResultsOnTurnEnd();
|
|
2217
2502
|
yield { turn: this._turn, role: "done", content: assistantContent };
|
|
2218
2503
|
return;
|
|
2219
2504
|
}
|
|
2220
2505
|
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
|
|
2221
2506
|
if (usage) {
|
|
2222
2507
|
const ratio = usage.promptTokens / ctxMax;
|
|
2223
|
-
if (ratio > 0.
|
|
2508
|
+
if (ratio > 0.4 && ratio <= 0.8) {
|
|
2224
2509
|
const before = usage.promptTokens;
|
|
2225
2510
|
const soft = this.compact(4e3);
|
|
2226
2511
|
if (soft.healedCount > 0) {
|
|
@@ -2318,6 +2603,14 @@ ${reason}`;
|
|
|
2318
2603
|
name,
|
|
2319
2604
|
content: result
|
|
2320
2605
|
});
|
|
2606
|
+
this.compactToolCallArgsAfterResponse();
|
|
2607
|
+
if (this.noteToolFailureSignal(result)) {
|
|
2608
|
+
yield {
|
|
2609
|
+
turn: this._turn,
|
|
2610
|
+
role: "warning",
|
|
2611
|
+
content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailureCount} edit failure(s). Next turn falls back to ${this.model} unless /pro is armed.`
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2321
2614
|
yield {
|
|
2322
2615
|
turn: this._turn,
|
|
2323
2616
|
role: "tool",
|
|
@@ -2341,13 +2634,15 @@ ${reason}`;
|
|
|
2341
2634
|
role: "user",
|
|
2342
2635
|
content: "I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks \u2014 they will be silently discarded. Just plain text."
|
|
2343
2636
|
});
|
|
2637
|
+
const summaryModel = "deepseek-v4-flash";
|
|
2638
|
+
const summaryEffort = "high";
|
|
2344
2639
|
const resp = await this.client.chat({
|
|
2345
|
-
model:
|
|
2640
|
+
model: summaryModel,
|
|
2346
2641
|
messages,
|
|
2347
2642
|
// no tools → model is forced to answer in text
|
|
2348
2643
|
signal: this._turnAbort.signal,
|
|
2349
|
-
thinking: thinkingModeForModel(
|
|
2350
|
-
reasoningEffort:
|
|
2644
|
+
thinking: thinkingModeForModel(summaryModel),
|
|
2645
|
+
reasoningEffort: summaryEffort
|
|
2351
2646
|
});
|
|
2352
2647
|
const rawContent = resp.content?.trim() ?? "";
|
|
2353
2648
|
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
|
@@ -2356,7 +2651,7 @@ ${reason}`;
|
|
|
2356
2651
|
const annotated = `${reasonPrefix}
|
|
2357
2652
|
|
|
2358
2653
|
${summary}`;
|
|
2359
|
-
const summaryStats = this.stats.record(this._turn,
|
|
2654
|
+
const summaryStats = this.stats.record(this._turn, summaryModel, resp.usage ?? new Usage());
|
|
2360
2655
|
this.appendAndPersist(this.assistantMessage(summary, [], resp.reasoningContent ?? void 0));
|
|
2361
2656
|
yield {
|
|
2362
2657
|
turn: this._turn,
|
|
@@ -2365,6 +2660,7 @@ ${summary}`;
|
|
|
2365
2660
|
stats: summaryStats,
|
|
2366
2661
|
forcedSummary: true
|
|
2367
2662
|
};
|
|
2663
|
+
this.autoCompactToolResultsOnTurnEnd();
|
|
2368
2664
|
yield { turn: this._turn, role: "done", content: summary };
|
|
2369
2665
|
} catch (err) {
|
|
2370
2666
|
const label = errorLabelFor(opts.reason, this.maxToolIters);
|
|
@@ -2374,6 +2670,7 @@ ${summary}`;
|
|
|
2374
2670
|
content: "",
|
|
2375
2671
|
error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
|
|
2376
2672
|
};
|
|
2673
|
+
this.autoCompactToolResultsOnTurnEnd();
|
|
2377
2674
|
yield { turn: this._turn, role: "done", content: "" };
|
|
2378
2675
|
}
|
|
2379
2676
|
}
|
|
@@ -2503,6 +2800,56 @@ function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
|
|
|
2503
2800
|
});
|
|
2504
2801
|
return { messages: out, healedCount, tokensSaved, charsSaved };
|
|
2505
2802
|
}
|
|
2803
|
+
function shrinkOversizedToolCallArgsByTokens(messages, maxTokens) {
|
|
2804
|
+
let healedCount = 0;
|
|
2805
|
+
let tokensSaved = 0;
|
|
2806
|
+
let charsSaved = 0;
|
|
2807
|
+
const out = messages.map((msg) => {
|
|
2808
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
|
|
2809
|
+
let changed = false;
|
|
2810
|
+
const newCalls = msg.tool_calls.map((call) => {
|
|
2811
|
+
const args = call.function?.arguments;
|
|
2812
|
+
if (typeof args !== "string" || args.length <= maxTokens) return call;
|
|
2813
|
+
const beforeTokens = countTokens(args);
|
|
2814
|
+
if (beforeTokens <= maxTokens) return call;
|
|
2815
|
+
const shrunk = shrinkJsonLongStrings(args);
|
|
2816
|
+
const afterTokens = countTokens(shrunk);
|
|
2817
|
+
if (afterTokens >= beforeTokens) return call;
|
|
2818
|
+
changed = true;
|
|
2819
|
+
healedCount += 1;
|
|
2820
|
+
tokensSaved += beforeTokens - afterTokens;
|
|
2821
|
+
charsSaved += args.length - shrunk.length;
|
|
2822
|
+
return { ...call, function: { ...call.function, arguments: shrunk } };
|
|
2823
|
+
});
|
|
2824
|
+
if (!changed) return msg;
|
|
2825
|
+
return { ...msg, tool_calls: newCalls };
|
|
2826
|
+
});
|
|
2827
|
+
return { messages: out, healedCount, tokensSaved, charsSaved };
|
|
2828
|
+
}
|
|
2829
|
+
function shrinkJsonLongStrings(jsonStr) {
|
|
2830
|
+
let parsed;
|
|
2831
|
+
try {
|
|
2832
|
+
parsed = JSON.parse(jsonStr);
|
|
2833
|
+
} catch {
|
|
2834
|
+
const head = jsonStr.slice(0, 200);
|
|
2835
|
+
return `${head}\u2026[shrunk: ${jsonStr.length} chars, unparsed]`;
|
|
2836
|
+
}
|
|
2837
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2838
|
+
return jsonStr;
|
|
2839
|
+
}
|
|
2840
|
+
const LONG_THRESHOLD = 300;
|
|
2841
|
+
const input = parsed;
|
|
2842
|
+
const output = {};
|
|
2843
|
+
for (const [k, v] of Object.entries(input)) {
|
|
2844
|
+
if (typeof v === "string" && v.length > LONG_THRESHOLD) {
|
|
2845
|
+
const newlines = v.match(/\n/g)?.length ?? 0;
|
|
2846
|
+
output[k] = `[\u2026shrunk: ${v.length} chars, ${newlines} lines \u2014 tool already responded, see result]`;
|
|
2847
|
+
} else {
|
|
2848
|
+
output[k] = v;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
return JSON.stringify(output);
|
|
2852
|
+
}
|
|
2506
2853
|
function fixToolCallPairing(messages) {
|
|
2507
2854
|
const out = [];
|
|
2508
2855
|
let droppedAssistantCalls = 0;
|
|
@@ -2855,6 +3202,28 @@ import { join as join7, resolve as resolve3 } from "path";
|
|
|
2855
3202
|
import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
|
|
2856
3203
|
import { homedir as homedir3 } from "os";
|
|
2857
3204
|
import { join as join6, resolve as resolve2 } from "path";
|
|
3205
|
+
|
|
3206
|
+
// src/prompt-fragments.ts
|
|
3207
|
+
var TUI_FORMATTING_RULES = `Formatting (rendered in a TUI with a real markdown renderer):
|
|
3208
|
+
- Tabular data \u2192 GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` header + \`| --- | --- |\` separator). Never use Unicode box-drawing characters (\u2502 \u2500 \u253C \u250C \u2510 \u2514 \u2518 \u251C \u2524) \u2014 they look intentional but break terminal word-wrap and render as garbled columns at narrow widths.
|
|
3209
|
+
- Keep table cells short (one phrase each). If a cell needs a paragraph, use bullets below the table instead.
|
|
3210
|
+
- Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
|
|
3211
|
+
- Do NOT draw decorative frames around content with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` characters. The renderer adds its own borders; extra ASCII art adds noise and shatters at narrow widths.
|
|
3212
|
+
- For flow charts and diagrams: a plain bullet list with \`\u2192\` or \`\u2193\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
|
|
3213
|
+
var ESCALATION_CONTRACT = `Cost-aware escalation (when you're running on deepseek-v4-flash):
|
|
3214
|
+
|
|
3215
|
+
If a task CLEARLY exceeds what flash can do well \u2014 complex cross-file architecture refactors, subtle concurrency / security / correctness invariants you can't resolve with confidence, or a design trade-off you'd be guessing at \u2014 output the exact string \`<<<NEEDS_PRO>>>\` as the FIRST line of your response (nothing before it, not even whitespace on a separate line). This aborts the current call and retries this turn on deepseek-v4-pro, one shot. Do NOT emit any other content in the same response when you request escalation.
|
|
3216
|
+
|
|
3217
|
+
Use this sparingly. Normal tasks \u2014 reading files, small edits, clear bug fixes, straightforward feature additions \u2014 stay on flash. Request escalation ONLY when you would otherwise produce a guess or a visibly-mediocre answer. If in doubt, attempt the task on flash first; the system also escalates automatically if you hit 3+ repair / SEARCH-mismatch errors in a single turn.`;
|
|
3218
|
+
var NEGATIVE_CLAIM_RULE = `Negative claims ("X is missing", "Y isn't implemented", "there's no Z") are the #1 hallucination shape. They feel safe to write because no citation seems possible \u2014 but that's exactly why you must NOT write them on instinct.
|
|
3219
|
+
|
|
3220
|
+
If you have a search tool (\`search_content\`, \`grep\`, web search), call it FIRST before asserting absence:
|
|
3221
|
+
- Returns matches \u2192 you were wrong; correct yourself and cite the matches.
|
|
3222
|
+
- Returns nothing \u2192 state the absence WITH the search query as evidence: \`No callers of \\\`foo()\\\` found (search_content "foo").\`
|
|
3223
|
+
|
|
3224
|
+
If you have no search tool, qualify hard: "I haven't verified \u2014 this is a guess." Never assert absence with fake authority.`;
|
|
3225
|
+
|
|
3226
|
+
// src/skills.ts
|
|
2858
3227
|
var SKILLS_DIRNAME = "skills";
|
|
2859
3228
|
var SKILL_FILE = "SKILL.md";
|
|
2860
3229
|
var SKILLS_INDEX_MAX_CHARS = 4e3;
|
|
@@ -2997,10 +3366,10 @@ function parseRunAs(raw) {
|
|
|
2997
3366
|
}
|
|
2998
3367
|
function skillIndexLine(s) {
|
|
2999
3368
|
const safeDesc = s.description.replace(/\n/g, " ").trim();
|
|
3000
|
-
const
|
|
3001
|
-
const max = 130 - s.name.length -
|
|
3369
|
+
const tag = s.runAs === "subagent" ? " [\u{1F9EC} subagent]" : "";
|
|
3370
|
+
const max = 130 - s.name.length - tag.length;
|
|
3002
3371
|
const clipped = safeDesc.length > max ? `${safeDesc.slice(0, Math.max(1, max - 1))}\u2026` : safeDesc;
|
|
3003
|
-
return clipped ? `- ${
|
|
3372
|
+
return clipped ? `- ${s.name}${tag} \u2014 ${clipped}` : `- ${s.name}${tag}`;
|
|
3004
3373
|
}
|
|
3005
3374
|
function applySkillsIndex(basePrompt, opts = {}) {
|
|
3006
3375
|
const store = new SkillStore(opts);
|
|
@@ -3015,7 +3384,7 @@ function applySkillsIndex(basePrompt, opts = {}) {
|
|
|
3015
3384
|
"",
|
|
3016
3385
|
"# Skills \u2014 playbooks you can invoke",
|
|
3017
3386
|
"",
|
|
3018
|
-
'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })`
|
|
3387
|
+
'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })` \u2014 the `name` is JUST the skill identifier (e.g. `"explore"`), NOT the `[\u{1F9EC} subagent]` tag that appears after it. Entries tagged `[\u{1F9EC} subagent]` spawn an **isolated subagent** \u2014 its tool calls and reasoning never enter your context, only its final answer does. Use subagent skills for tasks that would otherwise flood your context (deep exploration, multi-step research, anything where you only need the conclusion). Plain skills are inlined: their body becomes a tool result you read and act on directly. The user can also invoke a skill via `/skill <name>`.',
|
|
3019
3388
|
"",
|
|
3020
3389
|
"```",
|
|
3021
3390
|
truncated,
|
|
@@ -3037,12 +3406,9 @@ Your final answer:
|
|
|
3037
3406
|
- If the question can't be answered from what you found, say so plainly and suggest where to look next.
|
|
3038
3407
|
- No follow-up offers, no "let me know if you need more." The parent will ask again if they need more.
|
|
3039
3408
|
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
- Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
|
|
3044
|
-
- NEVER draw decorative frames around code or text with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
|
|
3045
|
-
- For flow charts: use a bullet list with \`\u2192\` or \`\u2193\` between steps, not ASCII boxes-and-arrows.
|
|
3409
|
+
${NEGATIVE_CLAIM_RULE}
|
|
3410
|
+
|
|
3411
|
+
${TUI_FORMATTING_RULES}
|
|
3046
3412
|
|
|
3047
3413
|
The 'task' the parent gave you is the question you must answer. Treat any other reading of it as scope creep.`;
|
|
3048
3414
|
var BUILTIN_RESEARCH_BODY = `You are running as a research subagent. Your job is to gather information from code AND the web, synthesize it, and return one focused conclusion.
|
|
@@ -3059,12 +3425,9 @@ Your final answer:
|
|
|
3059
3425
|
- Distinguish "I verified this in code" from "I read this on a docs page" \u2014 the parent will trust the former more.
|
|
3060
3426
|
- If the answer is uncertain, say so. Don't invent confidence.
|
|
3061
3427
|
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
- Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
|
|
3066
|
-
- NEVER draw decorative frames around code or text with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
|
|
3067
|
-
- For flow charts: use a bullet list with \`\u2192\` or \`\u2193\` between steps, not ASCII boxes-and-arrows.
|
|
3428
|
+
${NEGATIVE_CLAIM_RULE}
|
|
3429
|
+
|
|
3430
|
+
${TUI_FORMATTING_RULES}
|
|
3068
3431
|
|
|
3069
3432
|
The 'task' the parent gave you is the research question. Stay on it.`;
|
|
3070
3433
|
var BUILTIN_SKILLS = Object.freeze([
|
|
@@ -3366,6 +3729,9 @@ import { promises as fs } from "fs";
|
|
|
3366
3729
|
import * as pathMod from "path";
|
|
3367
3730
|
var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
3368
3731
|
var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
|
|
3732
|
+
var DEFAULT_AUTO_PREVIEW_LINES = 200;
|
|
3733
|
+
var AUTO_PREVIEW_HEAD_LINES = 80;
|
|
3734
|
+
var AUTO_PREVIEW_TAIL_LINES = 40;
|
|
3369
3735
|
var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
3370
3736
|
"node_modules",
|
|
3371
3737
|
".git",
|
|
@@ -3458,14 +3824,22 @@ function registerFilesystemTools(registry, opts) {
|
|
|
3458
3824
|
};
|
|
3459
3825
|
registry.register({
|
|
3460
3826
|
name: "read_file",
|
|
3461
|
-
description:
|
|
3827
|
+
description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
|
|
3828
|
+
- head: N \u2192 first N lines (imports, public API, small configs)
|
|
3829
|
+
- tail: N \u2192 last N lines (recently-added code, log tails)
|
|
3830
|
+
- range: "A-B" \u2192 inclusive line range A..B, 1-indexed (e.g. "120-180" around an edit site)
|
|
3831
|
+
When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_LINES} lines, the tool auto-returns a head+tail preview with an "N lines omitted" marker rather than dumping everything. If you need the middle, re-call with a range. Prefer search_content to locate a symbol first, then read_file with a range around the hit \u2014 one scoped read beats three full-file reads.`,
|
|
3462
3832
|
readOnly: true,
|
|
3463
3833
|
parameters: {
|
|
3464
3834
|
type: "object",
|
|
3465
3835
|
properties: {
|
|
3466
3836
|
path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
|
|
3467
3837
|
head: { type: "integer", description: "If set, return only the first N lines." },
|
|
3468
|
-
tail: { type: "integer", description: "If set, return only the last N lines." }
|
|
3838
|
+
tail: { type: "integer", description: "If set, return only the last N lines." },
|
|
3839
|
+
range: {
|
|
3840
|
+
type: "string",
|
|
3841
|
+
description: 'Inclusive line range like "50-100" or "50-50". 1-indexed. Takes precedence over head/tail when all three are set. Out-of-range requests clamp to file bounds.'
|
|
3842
|
+
}
|
|
3469
3843
|
},
|
|
3470
3844
|
required: ["path"]
|
|
3471
3845
|
},
|
|
@@ -3477,21 +3851,52 @@ function registerFilesystemTools(registry, opts) {
|
|
|
3477
3851
|
}
|
|
3478
3852
|
const raw = await fs.readFile(abs);
|
|
3479
3853
|
if (raw.length > maxReadBytes) {
|
|
3480
|
-
const
|
|
3481
|
-
return `${
|
|
3854
|
+
const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
|
|
3855
|
+
return `${headBytes}
|
|
3482
3856
|
|
|
3483
|
-
[\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
|
|
3857
|
+
[\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
|
|
3484
3858
|
}
|
|
3485
3859
|
const text = raw.toString("utf8");
|
|
3860
|
+
let lines = text.split(/\r?\n/);
|
|
3861
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
|
|
3862
|
+
const totalLines = lines.length;
|
|
3863
|
+
if (typeof args.range === "string" && /^\d+\s*-\s*\d+$/.test(args.range)) {
|
|
3864
|
+
const [rawStart, rawEnd] = args.range.split("-").map((s) => Number.parseInt(s, 10));
|
|
3865
|
+
const start = Math.max(1, rawStart ?? 1);
|
|
3866
|
+
const end = Math.min(totalLines, Math.max(start, rawEnd ?? totalLines));
|
|
3867
|
+
const slice = lines.slice(start - 1, end);
|
|
3868
|
+
const label = `[range ${start}-${end} of ${totalLines} lines]`;
|
|
3869
|
+
return `${label}
|
|
3870
|
+
${slice.join("\n")}`;
|
|
3871
|
+
}
|
|
3486
3872
|
if (typeof args.head === "number" && args.head > 0) {
|
|
3487
|
-
|
|
3873
|
+
const count = Math.min(args.head, totalLines);
|
|
3874
|
+
const slice = lines.slice(0, count);
|
|
3875
|
+
const marker = count < totalLines ? `
|
|
3876
|
+
|
|
3877
|
+
[\u2026head ${count} of ${totalLines} lines \u2014 call again with range / tail for more]` : "";
|
|
3878
|
+
return slice.join("\n") + marker;
|
|
3488
3879
|
}
|
|
3489
3880
|
if (typeof args.tail === "number" && args.tail > 0) {
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3881
|
+
const count = Math.min(args.tail, totalLines);
|
|
3882
|
+
const slice = lines.slice(totalLines - count);
|
|
3883
|
+
const marker = count < totalLines ? `[\u2026tail ${count} of ${totalLines} lines \u2014 call again with range / head for more]
|
|
3884
|
+
|
|
3885
|
+
` : "";
|
|
3886
|
+
return marker + slice.join("\n");
|
|
3493
3887
|
}
|
|
3494
|
-
return
|
|
3888
|
+
if (totalLines <= DEFAULT_AUTO_PREVIEW_LINES) return lines.join("\n");
|
|
3889
|
+
const head = lines.slice(0, AUTO_PREVIEW_HEAD_LINES).join("\n");
|
|
3890
|
+
const tail = lines.slice(totalLines - AUTO_PREVIEW_TAIL_LINES).join("\n");
|
|
3891
|
+
const omitted = totalLines - AUTO_PREVIEW_HEAD_LINES - AUTO_PREVIEW_TAIL_LINES;
|
|
3892
|
+
return [
|
|
3893
|
+
`[auto-preview: head ${AUTO_PREVIEW_HEAD_LINES} + tail ${AUTO_PREVIEW_TAIL_LINES} of ${totalLines} lines]`,
|
|
3894
|
+
head,
|
|
3895
|
+
`
|
|
3896
|
+
[\u2026 ${omitted} lines omitted \u2014 call read_file again with range:"A-B" (1-indexed) or head / tail to get the middle]
|
|
3897
|
+
`,
|
|
3898
|
+
tail
|
|
3899
|
+
].join("\n");
|
|
3495
3900
|
}
|
|
3496
3901
|
});
|
|
3497
3902
|
registry.register({
|
|
@@ -3516,21 +3921,34 @@ function registerFilesystemTools(registry, opts) {
|
|
|
3516
3921
|
});
|
|
3517
3922
|
registry.register({
|
|
3518
3923
|
name: "directory_tree",
|
|
3519
|
-
description:
|
|
3924
|
+
description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
|
|
3925
|
+
- maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
|
|
3926
|
+
- Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
|
|
3927
|
+
- Large subtrees (>50 children) auto-collapse to "[N files, M dirs hidden \u2014 list_directory <path> to inspect]" so one huge folder can't dominate the output.
|
|
3928
|
+
Prefer \`list_directory\` for a single-level view, \`search_files\` to find specific paths, and \`search_content\` to find code.`,
|
|
3520
3929
|
readOnly: true,
|
|
3521
3930
|
parameters: {
|
|
3522
3931
|
type: "object",
|
|
3523
3932
|
properties: {
|
|
3524
3933
|
path: { type: "string", description: "Root of the tree (default: sandbox root)." },
|
|
3525
|
-
maxDepth: {
|
|
3934
|
+
maxDepth: {
|
|
3935
|
+
type: "integer",
|
|
3936
|
+
description: "Max recursion depth (default 2). Depth 0 shows only the top-level entries; depth 2 is usually enough to see module structure."
|
|
3937
|
+
},
|
|
3938
|
+
include_deps: {
|
|
3939
|
+
type: "boolean",
|
|
3940
|
+
description: "When true, also traverse node_modules / .git / dist / build / etc. Off by default \u2014 most exploration questions are about the user's own code."
|
|
3941
|
+
}
|
|
3526
3942
|
}
|
|
3527
3943
|
},
|
|
3528
3944
|
fn: async (args) => {
|
|
3529
3945
|
const startAbs = safePath(args.path ?? ".");
|
|
3530
|
-
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth :
|
|
3946
|
+
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 2;
|
|
3947
|
+
const includeDeps = args.include_deps === true;
|
|
3531
3948
|
const lines = [];
|
|
3532
3949
|
let totalBytes = 0;
|
|
3533
3950
|
let truncated = false;
|
|
3951
|
+
const PER_DIR_CHILD_CAP = 50;
|
|
3534
3952
|
const walk2 = async (dir, depth) => {
|
|
3535
3953
|
if (truncated) return;
|
|
3536
3954
|
if (depth > maxDepth) return;
|
|
@@ -3541,10 +3959,27 @@ function registerFilesystemTools(registry, opts) {
|
|
|
3541
3959
|
return;
|
|
3542
3960
|
}
|
|
3543
3961
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
3962
|
+
let emitted = 0;
|
|
3544
3963
|
for (const e of entries) {
|
|
3545
3964
|
if (truncated) return;
|
|
3965
|
+
const skip = e.isDirectory() && !includeDeps && SKIP_DIR_NAMES.has(e.name);
|
|
3966
|
+
if (emitted >= PER_DIR_CHILD_CAP) {
|
|
3967
|
+
const remaining = entries.length - emitted;
|
|
3968
|
+
let restFiles = 0;
|
|
3969
|
+
let restDirs = 0;
|
|
3970
|
+
for (const r of entries.slice(emitted)) {
|
|
3971
|
+
if (r.isDirectory()) restDirs++;
|
|
3972
|
+
else restFiles++;
|
|
3973
|
+
}
|
|
3974
|
+
const indent2 = " ".repeat(depth);
|
|
3975
|
+
lines.push(
|
|
3976
|
+
`${indent2}[\u2026 ${remaining} entries hidden (${restDirs} dirs, ${restFiles} files) \u2014 list_directory on this path to see all]`
|
|
3977
|
+
);
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3546
3980
|
const indent = " ".repeat(depth);
|
|
3547
|
-
const
|
|
3981
|
+
const suffix = skip ? " (skipped \u2014 pass include_deps:true to traverse)" : "";
|
|
3982
|
+
const line = e.isDirectory() ? `${indent}${e.name}/${suffix}` : `${indent}${e.name}`;
|
|
3548
3983
|
totalBytes += line.length + 1;
|
|
3549
3984
|
if (totalBytes > maxListBytes) {
|
|
3550
3985
|
lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
|
|
@@ -3552,7 +3987,8 @@ function registerFilesystemTools(registry, opts) {
|
|
|
3552
3987
|
return;
|
|
3553
3988
|
}
|
|
3554
3989
|
lines.push(line);
|
|
3555
|
-
|
|
3990
|
+
emitted++;
|
|
3991
|
+
if (e.isDirectory() && !skip) {
|
|
3556
3992
|
await walk2(pathMod.join(dir, e.name), depth + 1);
|
|
3557
3993
|
}
|
|
3558
3994
|
}
|
|
@@ -4057,15 +4493,15 @@ Rules:
|
|
|
4057
4493
|
- When you're done, your final assistant message is the only thing the parent will see \u2014 make it complete and self-contained. No follow-up offers, no questions, no "let me know if you need more."
|
|
4058
4494
|
- Prefer one clear, distilled answer over a long log of what you tried.
|
|
4059
4495
|
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
- For flow charts and diagrams: use a markdown bullet list with \`\u2192\` or \`\u2193\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
|
|
4496
|
+
${NEGATIVE_CLAIM_RULE}
|
|
4497
|
+
|
|
4498
|
+
${ESCALATION_CONTRACT}
|
|
4499
|
+
|
|
4500
|
+
${TUI_FORMATTING_RULES}`;
|
|
4066
4501
|
var DEFAULT_MAX_RESULT_CHARS2 = 8e3;
|
|
4067
4502
|
var DEFAULT_MAX_ITERS = 16;
|
|
4068
|
-
var DEFAULT_SUBAGENT_MODEL = "deepseek-v4-
|
|
4503
|
+
var DEFAULT_SUBAGENT_MODEL = "deepseek-v4-flash";
|
|
4504
|
+
var DEFAULT_SUBAGENT_EFFORT = "high";
|
|
4069
4505
|
var SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
4070
4506
|
var NEVER_INHERITED_TOOLS = /* @__PURE__ */ new Set([SUBAGENT_TOOL_NAME, "submit_plan"]);
|
|
4071
4507
|
async function spawnSubagent(opts) {
|
|
@@ -4094,6 +4530,10 @@ async function spawnSubagent(opts) {
|
|
|
4094
4530
|
prefix: childPrefix,
|
|
4095
4531
|
tools: childTools,
|
|
4096
4532
|
model,
|
|
4533
|
+
// Subagents run on a constrained thinking budget by default — the
|
|
4534
|
+
// task is already narrow by construction, and `high` cuts output
|
|
4535
|
+
// tokens substantially vs `max`.
|
|
4536
|
+
reasoningEffort: DEFAULT_SUBAGENT_EFFORT,
|
|
4097
4537
|
maxToolIters,
|
|
4098
4538
|
hooks: [],
|
|
4099
4539
|
stream: false
|
|
@@ -4262,9 +4702,311 @@ function forkRegistryExcluding(parent, exclude) {
|
|
|
4262
4702
|
}
|
|
4263
4703
|
|
|
4264
4704
|
// src/tools/shell.ts
|
|
4265
|
-
import { spawn as
|
|
4705
|
+
import { spawn as spawn3 } from "child_process";
|
|
4266
4706
|
import { existsSync as existsSync8, statSync as statSync4 } from "fs";
|
|
4707
|
+
import * as pathMod3 from "path";
|
|
4708
|
+
|
|
4709
|
+
// src/tools/jobs.ts
|
|
4710
|
+
import { spawn as spawn2 } from "child_process";
|
|
4267
4711
|
import * as pathMod2 from "path";
|
|
4712
|
+
function killProcessTree(pid, signal) {
|
|
4713
|
+
if (process.platform === "win32") {
|
|
4714
|
+
const args = ["/pid", String(pid), "/T"];
|
|
4715
|
+
if (signal === "SIGKILL") args.push("/F");
|
|
4716
|
+
try {
|
|
4717
|
+
const killer = spawn2("taskkill", args, {
|
|
4718
|
+
stdio: "ignore",
|
|
4719
|
+
windowsHide: true
|
|
4720
|
+
});
|
|
4721
|
+
killer.on("error", () => {
|
|
4722
|
+
});
|
|
4723
|
+
} catch {
|
|
4724
|
+
}
|
|
4725
|
+
return;
|
|
4726
|
+
}
|
|
4727
|
+
try {
|
|
4728
|
+
process.kill(-pid, signal);
|
|
4729
|
+
return;
|
|
4730
|
+
} catch {
|
|
4731
|
+
}
|
|
4732
|
+
try {
|
|
4733
|
+
process.kill(pid, signal);
|
|
4734
|
+
} catch {
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
var DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
|
|
4738
|
+
var READY_SIGNALS = [
|
|
4739
|
+
// HTTP server banners
|
|
4740
|
+
/\blistening on\b/i,
|
|
4741
|
+
/\blocal:\s+https?:\/\//i,
|
|
4742
|
+
/\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?\b/i,
|
|
4743
|
+
/\b(?:ready|server started|started server|app listening)\b/i,
|
|
4744
|
+
// Bundlers / compilers
|
|
4745
|
+
/\bcompiled successfully\b/i,
|
|
4746
|
+
/\bbuild complete(?:d)?\b/i,
|
|
4747
|
+
/\bwatching for (?:file )?changes\b/i,
|
|
4748
|
+
/\bready in \d+/i,
|
|
4749
|
+
// Generic
|
|
4750
|
+
/\bstartup (?:complete|finished)\b/i
|
|
4751
|
+
];
|
|
4752
|
+
var JobRegistry = class {
|
|
4753
|
+
jobs = /* @__PURE__ */ new Map();
|
|
4754
|
+
nextId = 1;
|
|
4755
|
+
/**
|
|
4756
|
+
* Spawn a background child. Resolves after `waitSec` OR on ready
|
|
4757
|
+
* signal OR on early exit, whichever comes first. The child continues
|
|
4758
|
+
* to run (and buffer output) regardless of which path fires.
|
|
4759
|
+
*/
|
|
4760
|
+
async start(command, opts) {
|
|
4761
|
+
const trimmed = command.trim();
|
|
4762
|
+
if (!trimmed) throw new Error("run_background: empty command");
|
|
4763
|
+
const op = detectShellOperator(trimmed);
|
|
4764
|
+
if (op !== null) {
|
|
4765
|
+
throw new Error(
|
|
4766
|
+
`run_background: shell operator "${op}" is not supported \u2014 spawn one process per background job. Compose via your orchestration, not the shell.`
|
|
4767
|
+
);
|
|
4768
|
+
}
|
|
4769
|
+
const argv = tokenizeCommand(trimmed);
|
|
4770
|
+
if (argv.length === 0) throw new Error("run_background: empty command");
|
|
4771
|
+
const waitMs = Math.max(0, Math.min(30, opts.waitSec ?? 3)) * 1e3;
|
|
4772
|
+
const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
|
|
4773
|
+
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
|
4774
|
+
const spawnOpts = {
|
|
4775
|
+
cwd: pathMod2.resolve(opts.cwd),
|
|
4776
|
+
shell: false,
|
|
4777
|
+
windowsHide: true,
|
|
4778
|
+
env: process.env,
|
|
4779
|
+
// POSIX: detach so the child becomes its own process-group leader.
|
|
4780
|
+
// Required for `process.kill(-pid, …)` later — without it a group
|
|
4781
|
+
// kill fails and we end up only signaling the wrapper, leaving
|
|
4782
|
+
// grandchildren (node → vite → esbuild …) orphaned.
|
|
4783
|
+
// Windows: detached would spawn a new console window; leave the
|
|
4784
|
+
// default and use taskkill /T for tree termination.
|
|
4785
|
+
detached: process.platform !== "win32",
|
|
4786
|
+
...spawnOverrides
|
|
4787
|
+
};
|
|
4788
|
+
let child;
|
|
4789
|
+
try {
|
|
4790
|
+
child = spawn2(bin, args, spawnOpts);
|
|
4791
|
+
} catch (err) {
|
|
4792
|
+
const id2 = this.nextId++;
|
|
4793
|
+
const job2 = {
|
|
4794
|
+
id: id2,
|
|
4795
|
+
command: trimmed,
|
|
4796
|
+
pid: null,
|
|
4797
|
+
startedAt: Date.now(),
|
|
4798
|
+
exitCode: null,
|
|
4799
|
+
output: `[spawn failed] ${err.message}`,
|
|
4800
|
+
totalBytesWritten: 0,
|
|
4801
|
+
running: false,
|
|
4802
|
+
spawnError: err.message,
|
|
4803
|
+
child: null,
|
|
4804
|
+
readyPromise: Promise.resolve(),
|
|
4805
|
+
signalReady: () => {
|
|
4806
|
+
}
|
|
4807
|
+
};
|
|
4808
|
+
this.jobs.set(id2, job2);
|
|
4809
|
+
return {
|
|
4810
|
+
jobId: id2,
|
|
4811
|
+
pid: null,
|
|
4812
|
+
stillRunning: false,
|
|
4813
|
+
readyMatched: false,
|
|
4814
|
+
preview: job2.output,
|
|
4815
|
+
exitCode: null
|
|
4816
|
+
};
|
|
4817
|
+
}
|
|
4818
|
+
const id = this.nextId++;
|
|
4819
|
+
let readyResolve = () => {
|
|
4820
|
+
};
|
|
4821
|
+
const readyPromise = new Promise((res) => {
|
|
4822
|
+
readyResolve = res;
|
|
4823
|
+
});
|
|
4824
|
+
const job = {
|
|
4825
|
+
id,
|
|
4826
|
+
command: trimmed,
|
|
4827
|
+
pid: child.pid ?? null,
|
|
4828
|
+
startedAt: Date.now(),
|
|
4829
|
+
exitCode: null,
|
|
4830
|
+
output: "",
|
|
4831
|
+
totalBytesWritten: 0,
|
|
4832
|
+
running: true,
|
|
4833
|
+
child,
|
|
4834
|
+
readyPromise,
|
|
4835
|
+
signalReady: readyResolve
|
|
4836
|
+
};
|
|
4837
|
+
this.jobs.set(id, job);
|
|
4838
|
+
let readyMatched = false;
|
|
4839
|
+
const onData = (chunk) => {
|
|
4840
|
+
const s = chunk.toString();
|
|
4841
|
+
job.totalBytesWritten += s.length;
|
|
4842
|
+
job.output += s;
|
|
4843
|
+
if (job.output.length > maxBytes) {
|
|
4844
|
+
const overflow = job.output.length - maxBytes;
|
|
4845
|
+
const cut = job.output.indexOf("\n", overflow);
|
|
4846
|
+
const start = cut >= 0 ? cut + 1 : overflow;
|
|
4847
|
+
job.output = `[\u2026 older output dropped \u2026]
|
|
4848
|
+
${job.output.slice(start)}`;
|
|
4849
|
+
}
|
|
4850
|
+
if (!readyMatched) {
|
|
4851
|
+
for (const re of READY_SIGNALS) {
|
|
4852
|
+
if (re.test(s) || re.test(job.output)) {
|
|
4853
|
+
readyMatched = true;
|
|
4854
|
+
job.signalReady();
|
|
4855
|
+
break;
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
}
|
|
4859
|
+
};
|
|
4860
|
+
child.stdout?.on("data", onData);
|
|
4861
|
+
child.stderr?.on("data", onData);
|
|
4862
|
+
child.on("error", (err) => {
|
|
4863
|
+
job.running = false;
|
|
4864
|
+
job.spawnError = err.message;
|
|
4865
|
+
job.signalReady();
|
|
4866
|
+
});
|
|
4867
|
+
child.on("close", (code) => {
|
|
4868
|
+
job.running = false;
|
|
4869
|
+
job.exitCode = code;
|
|
4870
|
+
job.signalReady();
|
|
4871
|
+
});
|
|
4872
|
+
const onAbort = () => this.stop(id, { graceMs: 100 });
|
|
4873
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
4874
|
+
let timer = null;
|
|
4875
|
+
await Promise.race([
|
|
4876
|
+
readyPromise,
|
|
4877
|
+
new Promise((res) => {
|
|
4878
|
+
timer = setTimeout(res, waitMs);
|
|
4879
|
+
})
|
|
4880
|
+
]);
|
|
4881
|
+
if (timer) clearTimeout(timer);
|
|
4882
|
+
return {
|
|
4883
|
+
jobId: id,
|
|
4884
|
+
pid: job.pid,
|
|
4885
|
+
stillRunning: job.running,
|
|
4886
|
+
readyMatched,
|
|
4887
|
+
preview: job.output,
|
|
4888
|
+
exitCode: job.exitCode
|
|
4889
|
+
};
|
|
4890
|
+
}
|
|
4891
|
+
/**
|
|
4892
|
+
* Read a job's accumulated output. `since` lets a caller poll
|
|
4893
|
+
* incrementally: pass the byte count returned from the last call to
|
|
4894
|
+
* get only newly-written content. Returns both full output and a
|
|
4895
|
+
* running snapshot so the caller can use whichever.
|
|
4896
|
+
*/
|
|
4897
|
+
read(id, opts = {}) {
|
|
4898
|
+
const job = this.jobs.get(id);
|
|
4899
|
+
if (!job) return null;
|
|
4900
|
+
const full = job.output;
|
|
4901
|
+
let slice = full;
|
|
4902
|
+
if (typeof opts.since === "number" && opts.since >= 0 && opts.since < full.length) {
|
|
4903
|
+
slice = full.slice(opts.since);
|
|
4904
|
+
}
|
|
4905
|
+
if (typeof opts.tailLines === "number" && opts.tailLines > 0) {
|
|
4906
|
+
const lines = slice.split("\n");
|
|
4907
|
+
const keep = lines.slice(Math.max(0, lines.length - opts.tailLines));
|
|
4908
|
+
slice = keep.join("\n");
|
|
4909
|
+
}
|
|
4910
|
+
return {
|
|
4911
|
+
output: slice,
|
|
4912
|
+
byteLength: full.length,
|
|
4913
|
+
running: job.running,
|
|
4914
|
+
exitCode: job.exitCode,
|
|
4915
|
+
command: job.command,
|
|
4916
|
+
pid: job.pid,
|
|
4917
|
+
spawnError: job.spawnError
|
|
4918
|
+
};
|
|
4919
|
+
}
|
|
4920
|
+
/**
|
|
4921
|
+
* Send SIGTERM, wait `graceMs`, then SIGKILL if still alive. Returns
|
|
4922
|
+
* the final job record (or null when the job id is unknown). Safe to
|
|
4923
|
+
* call on an already-exited job — returns the record unchanged.
|
|
4924
|
+
*/
|
|
4925
|
+
async stop(id, opts = {}) {
|
|
4926
|
+
const job = this.jobs.get(id);
|
|
4927
|
+
if (!job) return null;
|
|
4928
|
+
if (!job.running || !job.child) return snapshot(job);
|
|
4929
|
+
const graceMs = Math.max(0, opts.graceMs ?? 2e3);
|
|
4930
|
+
if (job.pid !== null) {
|
|
4931
|
+
killProcessTree(job.pid, "SIGTERM");
|
|
4932
|
+
} else {
|
|
4933
|
+
try {
|
|
4934
|
+
job.child.kill("SIGTERM");
|
|
4935
|
+
} catch {
|
|
4936
|
+
}
|
|
4937
|
+
}
|
|
4938
|
+
await Promise.race([job.readyPromise, new Promise((res) => setTimeout(res, graceMs))]);
|
|
4939
|
+
if (job.running) {
|
|
4940
|
+
if (job.pid !== null) {
|
|
4941
|
+
killProcessTree(job.pid, "SIGKILL");
|
|
4942
|
+
} else {
|
|
4943
|
+
try {
|
|
4944
|
+
job.child.kill("SIGKILL");
|
|
4945
|
+
} catch {
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
await new Promise((res) => setTimeout(res, 800));
|
|
4949
|
+
}
|
|
4950
|
+
return snapshot(job);
|
|
4951
|
+
}
|
|
4952
|
+
list() {
|
|
4953
|
+
return [...this.jobs.values()].map(snapshot);
|
|
4954
|
+
}
|
|
4955
|
+
/**
|
|
4956
|
+
* Best-effort kill of every still-running job. Called on TUI shutdown
|
|
4957
|
+
* so dev servers don't outlive the Reasonix process. Resolves after
|
|
4958
|
+
* every child has closed or a hard deadline passes (3s total).
|
|
4959
|
+
*/
|
|
4960
|
+
async shutdown(deadlineMs = 5e3) {
|
|
4961
|
+
const start = Date.now();
|
|
4962
|
+
const runningJobs = [...this.jobs.values()].filter((j) => j.running && j.child);
|
|
4963
|
+
if (runningJobs.length === 0) return;
|
|
4964
|
+
for (const job of runningJobs) {
|
|
4965
|
+
if (job.pid !== null) killProcessTree(job.pid, "SIGTERM");
|
|
4966
|
+
else
|
|
4967
|
+
try {
|
|
4968
|
+
job.child?.kill("SIGTERM");
|
|
4969
|
+
} catch {
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
const allClose = Promise.all(runningJobs.map((j) => j.readyPromise));
|
|
4973
|
+
const elapsed = () => Date.now() - start;
|
|
4974
|
+
const graceMs = Math.min(1500, Math.max(0, deadlineMs / 2));
|
|
4975
|
+
await Promise.race([allClose, new Promise((res) => setTimeout(res, graceMs))]);
|
|
4976
|
+
for (const job of runningJobs) {
|
|
4977
|
+
if (!job.running) continue;
|
|
4978
|
+
if (job.pid !== null) killProcessTree(job.pid, "SIGKILL");
|
|
4979
|
+
else
|
|
4980
|
+
try {
|
|
4981
|
+
job.child?.kill("SIGKILL");
|
|
4982
|
+
} catch {
|
|
4983
|
+
}
|
|
4984
|
+
}
|
|
4985
|
+
const remaining = Math.max(800, deadlineMs - elapsed());
|
|
4986
|
+
await Promise.race([allClose, new Promise((res) => setTimeout(res, remaining))]);
|
|
4987
|
+
}
|
|
4988
|
+
/** Count of still-running jobs — drives the TUI status-bar indicator. */
|
|
4989
|
+
runningCount() {
|
|
4990
|
+
let n = 0;
|
|
4991
|
+
for (const job of this.jobs.values()) if (job.running) n++;
|
|
4992
|
+
return n;
|
|
4993
|
+
}
|
|
4994
|
+
};
|
|
4995
|
+
function snapshot(job) {
|
|
4996
|
+
return {
|
|
4997
|
+
id: job.id,
|
|
4998
|
+
command: job.command,
|
|
4999
|
+
pid: job.pid,
|
|
5000
|
+
startedAt: job.startedAt,
|
|
5001
|
+
exitCode: job.exitCode,
|
|
5002
|
+
output: job.output,
|
|
5003
|
+
totalBytesWritten: job.totalBytesWritten,
|
|
5004
|
+
running: job.running,
|
|
5005
|
+
spawnError: job.spawnError
|
|
5006
|
+
};
|
|
5007
|
+
}
|
|
5008
|
+
|
|
5009
|
+
// src/tools/shell.ts
|
|
4268
5010
|
var DEFAULT_TIMEOUT_SEC = 60;
|
|
4269
5011
|
var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
|
|
4270
5012
|
var BUILTIN_ALLOWLIST = [
|
|
@@ -4433,10 +5175,10 @@ async function runCommand(cmd, opts) {
|
|
|
4433
5175
|
};
|
|
4434
5176
|
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
|
4435
5177
|
const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
|
|
4436
|
-
return await new Promise((
|
|
5178
|
+
return await new Promise((resolve9, reject) => {
|
|
4437
5179
|
let child;
|
|
4438
5180
|
try {
|
|
4439
|
-
child =
|
|
5181
|
+
child = spawn3(bin, args, effectiveSpawnOpts);
|
|
4440
5182
|
} catch (err) {
|
|
4441
5183
|
reject(err);
|
|
4442
5184
|
return;
|
|
@@ -4466,7 +5208,7 @@ async function runCommand(cmd, opts) {
|
|
|
4466
5208
|
const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
|
|
4467
5209
|
|
|
4468
5210
|
[\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
|
|
4469
|
-
|
|
5211
|
+
resolve9({ exitCode: code, output, timedOut });
|
|
4470
5212
|
});
|
|
4471
5213
|
});
|
|
4472
5214
|
}
|
|
@@ -4474,16 +5216,16 @@ function resolveExecutable(cmd, opts = {}) {
|
|
|
4474
5216
|
const platform = opts.platform ?? process.platform;
|
|
4475
5217
|
if (platform !== "win32") return cmd;
|
|
4476
5218
|
if (!cmd) return cmd;
|
|
4477
|
-
if (cmd.includes("/") || cmd.includes("\\") ||
|
|
4478
|
-
if (
|
|
5219
|
+
if (cmd.includes("/") || cmd.includes("\\") || pathMod3.isAbsolute(cmd)) return cmd;
|
|
5220
|
+
if (pathMod3.extname(cmd)) return cmd;
|
|
4479
5221
|
const env = opts.env ?? process.env;
|
|
4480
5222
|
const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
|
|
4481
|
-
const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" :
|
|
5223
|
+
const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod3.delimiter);
|
|
4482
5224
|
const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
|
|
4483
5225
|
const isFile = opts.isFile ?? defaultIsFile;
|
|
4484
5226
|
for (const dir of pathDirs) {
|
|
4485
5227
|
for (const ext of pathExt) {
|
|
4486
|
-
const full =
|
|
5228
|
+
const full = pathMod3.win32.join(dir, cmd + ext);
|
|
4487
5229
|
if (isFile(full)) return full;
|
|
4488
5230
|
}
|
|
4489
5231
|
}
|
|
@@ -4553,8 +5295,8 @@ function withUtf8Codepage(cmdline) {
|
|
|
4553
5295
|
function isBareWindowsName(s) {
|
|
4554
5296
|
if (!s) return false;
|
|
4555
5297
|
if (s.includes("/") || s.includes("\\")) return false;
|
|
4556
|
-
if (
|
|
4557
|
-
if (
|
|
5298
|
+
if (pathMod3.isAbsolute(s)) return false;
|
|
5299
|
+
if (pathMod3.extname(s)) return false;
|
|
4558
5300
|
return true;
|
|
4559
5301
|
}
|
|
4560
5302
|
function quoteForCmdExe(arg) {
|
|
@@ -4573,17 +5315,18 @@ var NeedsConfirmationError = class extends Error {
|
|
|
4573
5315
|
}
|
|
4574
5316
|
};
|
|
4575
5317
|
function registerShellTools(registry, opts) {
|
|
4576
|
-
const rootDir =
|
|
5318
|
+
const rootDir = pathMod3.resolve(opts.rootDir);
|
|
4577
5319
|
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
|
4578
5320
|
const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
5321
|
+
const jobs = opts.jobs ?? new JobRegistry();
|
|
4579
5322
|
const getExtraAllowed = typeof opts.extraAllowed === "function" ? opts.extraAllowed : (() => {
|
|
4580
|
-
const
|
|
4581
|
-
return () =>
|
|
5323
|
+
const snapshot2 = opts.extraAllowed ?? [];
|
|
5324
|
+
return () => snapshot2;
|
|
4582
5325
|
})();
|
|
4583
5326
|
const allowAll = opts.allowAll ?? false;
|
|
4584
5327
|
registry.register({
|
|
4585
5328
|
name: "run_command",
|
|
4586
|
-
description: "Run a shell command in the project root and return its combined stdout+stderr.
|
|
5329
|
+
description: "Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n\u2022 ONE process per call, NO shell expansion. `&&`, `||`, `|`, `;`, `>`, `<`, `2>&1` are all rejected up-front \u2014 split into separate calls and combine results in reasoning. Example: instead of `grep foo *.ts | wc -l`, use `grep -c foo *.ts`; instead of `cd sub && npm test`, use `npm test --prefix sub` (or whatever --cwd flag the binary accepts).\n\u2022 `cd` DOES NOT PERSIST between calls \u2014 each call spawns a fresh process rooted at the project. If a tool needs a subdirectory, pass it via the tool's own flag (`npm --prefix`, `cargo -C`, `git -C`, `pytest tests/\u2026`), NOT via a preceding `cd`.\n\u2022 Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) \u2014 they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
|
|
4587
5330
|
// Plan-mode gate: allow allowlisted commands through (git status,
|
|
4588
5331
|
// cargo check, ls, grep …) so the model can actually investigate
|
|
4589
5332
|
// during planning. Anything that would otherwise trigger a
|
|
@@ -4624,8 +5367,126 @@ function registerShellTools(registry, opts) {
|
|
|
4624
5367
|
return formatCommandResult(cmd, result);
|
|
4625
5368
|
}
|
|
4626
5369
|
});
|
|
5370
|
+
registry.register({
|
|
5371
|
+
name: "run_background",
|
|
5372
|
+
description: "Spawn a long-running process (dev server, watcher, any command that doesn't naturally exit) and detach. Waits up to `waitSec` seconds for startup (or until the output matches a readiness signal like 'Local:', 'listening on', 'compiled successfully'), then returns the job id + startup preview. The process keeps running; call `job_output` to tail its logs, `stop_job` to kill it, `list_jobs` to see all running jobs.\n\nSame shell constraints as run_command: NO `&&` / `||` / `|` / `;` / `>` / `<` / `2>&1`, `cd` doesn't persist. Dev servers that need a subdirectory: use the tool's own --prefix / --cwd flag. For Vite specifically, `--prefix` on npm only tells npm where package.json is; vite's server root still defaults to process cwd, so pass `vite <project-dir>` or configure via `vite.config.ts` root.\n\nUSE THIS \u2014 not `run_command` \u2014 for: npm/yarn/pnpm run dev, uvicorn / flask run, go run, cargo watch, tsc --watch, webpack serve, anything with 'dev' / 'serve' / 'watch' in the name.",
|
|
5373
|
+
parameters: {
|
|
5374
|
+
type: "object",
|
|
5375
|
+
properties: {
|
|
5376
|
+
command: {
|
|
5377
|
+
type: "string",
|
|
5378
|
+
description: "Full command line. Same quoting rules as run_command (no pipes / redirects / chaining)."
|
|
5379
|
+
},
|
|
5380
|
+
waitSec: {
|
|
5381
|
+
type: "integer",
|
|
5382
|
+
description: "Max seconds to wait for startup before returning. 0..30, default 3. A ready-signal match short-circuits this."
|
|
5383
|
+
}
|
|
5384
|
+
},
|
|
5385
|
+
required: ["command"]
|
|
5386
|
+
},
|
|
5387
|
+
fn: async (args, ctx) => {
|
|
5388
|
+
const cmd = args.command.trim();
|
|
5389
|
+
if (!cmd) throw new Error("run_background: empty command");
|
|
5390
|
+
if (!allowAll && !isAllowed(cmd, getExtraAllowed())) {
|
|
5391
|
+
throw new NeedsConfirmationError(cmd);
|
|
5392
|
+
}
|
|
5393
|
+
const result = await jobs.start(cmd, {
|
|
5394
|
+
cwd: rootDir,
|
|
5395
|
+
waitSec: args.waitSec,
|
|
5396
|
+
signal: ctx?.signal
|
|
5397
|
+
});
|
|
5398
|
+
return formatJobStart(result);
|
|
5399
|
+
}
|
|
5400
|
+
});
|
|
5401
|
+
registry.register({
|
|
5402
|
+
name: "job_output",
|
|
5403
|
+
description: "Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
|
|
5404
|
+
readOnly: true,
|
|
5405
|
+
parameters: {
|
|
5406
|
+
type: "object",
|
|
5407
|
+
properties: {
|
|
5408
|
+
jobId: { type: "integer", description: "Job id returned by run_background." },
|
|
5409
|
+
since: {
|
|
5410
|
+
type: "integer",
|
|
5411
|
+
description: "Return only output written past this byte offset (for incremental polling)."
|
|
5412
|
+
},
|
|
5413
|
+
tailLines: {
|
|
5414
|
+
type: "integer",
|
|
5415
|
+
description: "Cap the returned slice to the last N lines. Default 80, 0 = unlimited."
|
|
5416
|
+
}
|
|
5417
|
+
},
|
|
5418
|
+
required: ["jobId"]
|
|
5419
|
+
},
|
|
5420
|
+
fn: async (args) => {
|
|
5421
|
+
const out = jobs.read(args.jobId, {
|
|
5422
|
+
since: args.since,
|
|
5423
|
+
tailLines: args.tailLines ?? 80
|
|
5424
|
+
});
|
|
5425
|
+
if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
|
|
5426
|
+
return formatJobRead(args.jobId, out);
|
|
5427
|
+
}
|
|
5428
|
+
});
|
|
5429
|
+
registry.register({
|
|
5430
|
+
name: "stop_job",
|
|
5431
|
+
description: "Stop a background job started with `run_background`. SIGTERM first; SIGKILL after a short grace period if it doesn't exit cleanly. Returns the final output + exit code. Safe to call on an already-exited job.",
|
|
5432
|
+
parameters: {
|
|
5433
|
+
type: "object",
|
|
5434
|
+
properties: {
|
|
5435
|
+
jobId: { type: "integer" }
|
|
5436
|
+
},
|
|
5437
|
+
required: ["jobId"]
|
|
5438
|
+
},
|
|
5439
|
+
fn: async (args) => {
|
|
5440
|
+
const rec = await jobs.stop(args.jobId);
|
|
5441
|
+
if (!rec) return `job ${args.jobId}: not found`;
|
|
5442
|
+
return formatJobStop(rec);
|
|
5443
|
+
}
|
|
5444
|
+
});
|
|
5445
|
+
registry.register({
|
|
5446
|
+
name: "list_jobs",
|
|
5447
|
+
description: "List every background job started this session \u2014 running and exited \u2014 with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
|
|
5448
|
+
readOnly: true,
|
|
5449
|
+
parameters: { type: "object", properties: {} },
|
|
5450
|
+
fn: async () => {
|
|
5451
|
+
const all = jobs.list();
|
|
5452
|
+
if (all.length === 0) return "(no background jobs started this session)";
|
|
5453
|
+
return all.map(formatJobRow).join("\n");
|
|
5454
|
+
}
|
|
5455
|
+
});
|
|
4627
5456
|
return registry;
|
|
4628
5457
|
}
|
|
5458
|
+
function formatJobStart(r) {
|
|
5459
|
+
const header = r.stillRunning ? `[job ${r.jobId} started \xB7 pid ${r.pid ?? "?"} \xB7 ${r.readyMatched ? "READY signal matched" : "running (no ready signal yet)"}]` : r.exitCode !== null ? `[job ${r.jobId} exited during startup \xB7 exit ${r.exitCode}]` : `[job ${r.jobId} failed to start]`;
|
|
5460
|
+
return r.preview ? `${header}
|
|
5461
|
+
${r.preview}` : header;
|
|
5462
|
+
}
|
|
5463
|
+
function formatJobRead(jobId, r) {
|
|
5464
|
+
const status = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exited ${r.exitCode}` : r.spawnError ? `failed (${r.spawnError})` : "stopped";
|
|
5465
|
+
const header = `[job ${jobId} \xB7 ${status} \xB7 byteLength=${r.byteLength}]
|
|
5466
|
+
$ ${r.command}`;
|
|
5467
|
+
return r.output ? `${header}
|
|
5468
|
+
${r.output}` : header;
|
|
5469
|
+
}
|
|
5470
|
+
function formatJobStop(r) {
|
|
5471
|
+
const running = r.running ? "still running (SIGKILL may be pending)" : `exit ${r.exitCode ?? "?"}`;
|
|
5472
|
+
const tail = tailLines(r.output, 40);
|
|
5473
|
+
const header = `[job ${r.id} stopped \xB7 ${running}]
|
|
5474
|
+
$ ${r.command}`;
|
|
5475
|
+
return tail ? `${header}
|
|
5476
|
+
${tail}` : header;
|
|
5477
|
+
}
|
|
5478
|
+
function formatJobRow(r) {
|
|
5479
|
+
const age = ((Date.now() - r.startedAt) / 1e3).toFixed(1);
|
|
5480
|
+
const state = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exit ${r.exitCode}` : r.spawnError ? "failed" : "stopped";
|
|
5481
|
+
return ` ${String(r.id).padStart(3)} ${state.padEnd(24)} ${age}s ago $ ${r.command}`;
|
|
5482
|
+
}
|
|
5483
|
+
function tailLines(s, n) {
|
|
5484
|
+
if (!s) return "";
|
|
5485
|
+
const lines = s.split("\n");
|
|
5486
|
+
if (lines.length <= n) return s;
|
|
5487
|
+
const dropped = lines.length - n;
|
|
5488
|
+
return [`[\u2026 ${dropped} earlier lines \u2026]`, ...lines.slice(-n)].join("\n");
|
|
5489
|
+
}
|
|
4629
5490
|
function formatCommandResult(cmd, r) {
|
|
4630
5491
|
const header = r.timedOut ? `$ ${cmd}
|
|
4631
5492
|
[killed after timeout]` : `$ ${cmd}
|
|
@@ -4819,11 +5680,11 @@ ${i + 1}. ${r.title}`);
|
|
|
4819
5680
|
|
|
4820
5681
|
// src/env.ts
|
|
4821
5682
|
import { readFileSync as readFileSync8 } from "fs";
|
|
4822
|
-
import { resolve as
|
|
5683
|
+
import { resolve as resolve7 } from "path";
|
|
4823
5684
|
function loadDotenv(path = ".env") {
|
|
4824
5685
|
let raw;
|
|
4825
5686
|
try {
|
|
4826
|
-
raw = readFileSync8(
|
|
5687
|
+
raw = readFileSync8(resolve7(process.cwd(), path), "utf8");
|
|
4827
5688
|
} catch {
|
|
4828
5689
|
return;
|
|
4829
5690
|
}
|
|
@@ -5003,7 +5864,8 @@ function summarizeTurns(turns) {
|
|
|
5003
5864
|
claudeEquivalentUsd: round2(totalClaude, 6),
|
|
5004
5865
|
savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
|
|
5005
5866
|
cacheHitRatio: round2(cacheHitRatio, 4),
|
|
5006
|
-
lastPromptTokens: lastTurn?.usage.promptTokens ?? 0
|
|
5867
|
+
lastPromptTokens: lastTurn?.usage.promptTokens ?? 0,
|
|
5868
|
+
lastTurnCostUsd: round2(lastTurn?.cost ?? 0, 6)
|
|
5007
5869
|
};
|
|
5008
5870
|
}
|
|
5009
5871
|
function round2(n, digits) {
|
|
@@ -5505,7 +6367,7 @@ var McpClient = class {
|
|
|
5505
6367
|
const id = this.nextId++;
|
|
5506
6368
|
const frame = { jsonrpc: "2.0", id, method, params };
|
|
5507
6369
|
let abortHandler = null;
|
|
5508
|
-
const promise = new Promise((
|
|
6370
|
+
const promise = new Promise((resolve9, reject) => {
|
|
5509
6371
|
const timeout = setTimeout(() => {
|
|
5510
6372
|
this.pending.delete(id);
|
|
5511
6373
|
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
|
|
@@ -5514,7 +6376,7 @@ var McpClient = class {
|
|
|
5514
6376
|
);
|
|
5515
6377
|
}, this.requestTimeoutMs);
|
|
5516
6378
|
this.pending.set(id, {
|
|
5517
|
-
resolve:
|
|
6379
|
+
resolve: resolve9,
|
|
5518
6380
|
reject,
|
|
5519
6381
|
timeout
|
|
5520
6382
|
});
|
|
@@ -5596,7 +6458,7 @@ var McpClient = class {
|
|
|
5596
6458
|
};
|
|
5597
6459
|
|
|
5598
6460
|
// src/mcp/stdio.ts
|
|
5599
|
-
import { spawn as
|
|
6461
|
+
import { spawn as spawn4 } from "child_process";
|
|
5600
6462
|
var StdioTransport = class {
|
|
5601
6463
|
child;
|
|
5602
6464
|
queue = [];
|
|
@@ -5611,14 +6473,14 @@ var StdioTransport = class {
|
|
|
5611
6473
|
opts.command,
|
|
5612
6474
|
...(opts.args ?? []).map((a) => quoteArg(a, process.platform === "win32"))
|
|
5613
6475
|
].join(" ");
|
|
5614
|
-
this.child =
|
|
6476
|
+
this.child = spawn4(line, [], {
|
|
5615
6477
|
env,
|
|
5616
6478
|
cwd: opts.cwd,
|
|
5617
6479
|
stdio: ["pipe", "pipe", "inherit"],
|
|
5618
6480
|
shell: true
|
|
5619
6481
|
});
|
|
5620
6482
|
} else {
|
|
5621
|
-
this.child =
|
|
6483
|
+
this.child = spawn4(opts.command, opts.args ?? [], {
|
|
5622
6484
|
env,
|
|
5623
6485
|
cwd: opts.cwd,
|
|
5624
6486
|
stdio: ["pipe", "pipe", "inherit"]
|
|
@@ -5637,12 +6499,12 @@ var StdioTransport = class {
|
|
|
5637
6499
|
}
|
|
5638
6500
|
async send(message) {
|
|
5639
6501
|
if (this.closed) throw new Error("MCP transport is closed");
|
|
5640
|
-
return new Promise((
|
|
6502
|
+
return new Promise((resolve9, reject) => {
|
|
5641
6503
|
const line = `${JSON.stringify(message)}
|
|
5642
6504
|
`;
|
|
5643
6505
|
this.child.stdin.write(line, "utf8", (err) => {
|
|
5644
6506
|
if (err) reject(err);
|
|
5645
|
-
else
|
|
6507
|
+
else resolve9();
|
|
5646
6508
|
});
|
|
5647
6509
|
});
|
|
5648
6510
|
}
|
|
@@ -5653,8 +6515,8 @@ var StdioTransport = class {
|
|
|
5653
6515
|
continue;
|
|
5654
6516
|
}
|
|
5655
6517
|
if (this.closed) return;
|
|
5656
|
-
const next = await new Promise((
|
|
5657
|
-
this.waiters.push(
|
|
6518
|
+
const next = await new Promise((resolve9) => {
|
|
6519
|
+
this.waiters.push(resolve9);
|
|
5658
6520
|
});
|
|
5659
6521
|
if (next === null) return;
|
|
5660
6522
|
yield next;
|
|
@@ -5720,8 +6582,8 @@ var SseTransport = class {
|
|
|
5720
6582
|
constructor(opts) {
|
|
5721
6583
|
this.url = opts.url;
|
|
5722
6584
|
this.headers = opts.headers ?? {};
|
|
5723
|
-
this.endpointReady = new Promise((
|
|
5724
|
-
this.resolveEndpoint =
|
|
6585
|
+
this.endpointReady = new Promise((resolve9, reject) => {
|
|
6586
|
+
this.resolveEndpoint = resolve9;
|
|
5725
6587
|
this.rejectEndpoint = reject;
|
|
5726
6588
|
});
|
|
5727
6589
|
this.endpointReady.catch(() => void 0);
|
|
@@ -5748,8 +6610,8 @@ var SseTransport = class {
|
|
|
5748
6610
|
continue;
|
|
5749
6611
|
}
|
|
5750
6612
|
if (this.closed) return;
|
|
5751
|
-
const next = await new Promise((
|
|
5752
|
-
this.waiters.push(
|
|
6613
|
+
const next = await new Promise((resolve9) => {
|
|
6614
|
+
this.waiters.push(resolve9);
|
|
5753
6615
|
});
|
|
5754
6616
|
if (next === null) return;
|
|
5755
6617
|
yield next;
|
|
@@ -5949,7 +6811,7 @@ async function trySection(load) {
|
|
|
5949
6811
|
|
|
5950
6812
|
// src/code/edit-blocks.ts
|
|
5951
6813
|
import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
5952
|
-
import { dirname as dirname4, resolve as
|
|
6814
|
+
import { dirname as dirname4, resolve as resolve8 } from "path";
|
|
5953
6815
|
var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
|
|
5954
6816
|
function parseEditBlocks(text) {
|
|
5955
6817
|
const out = [];
|
|
@@ -5967,8 +6829,8 @@ function parseEditBlocks(text) {
|
|
|
5967
6829
|
return out;
|
|
5968
6830
|
}
|
|
5969
6831
|
function applyEditBlock(block, rootDir) {
|
|
5970
|
-
const absRoot =
|
|
5971
|
-
const absTarget =
|
|
6832
|
+
const absRoot = resolve8(rootDir);
|
|
6833
|
+
const absTarget = resolve8(absRoot, block.path);
|
|
5972
6834
|
if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
|
|
5973
6835
|
return {
|
|
5974
6836
|
path: block.path,
|
|
@@ -6018,13 +6880,13 @@ function applyEditBlocks(blocks, rootDir) {
|
|
|
6018
6880
|
return blocks.map((b) => applyEditBlock(b, rootDir));
|
|
6019
6881
|
}
|
|
6020
6882
|
function snapshotBeforeEdits(blocks, rootDir) {
|
|
6021
|
-
const absRoot =
|
|
6883
|
+
const absRoot = resolve8(rootDir);
|
|
6022
6884
|
const seen = /* @__PURE__ */ new Set();
|
|
6023
6885
|
const snapshots = [];
|
|
6024
6886
|
for (const b of blocks) {
|
|
6025
6887
|
if (seen.has(b.path)) continue;
|
|
6026
6888
|
seen.add(b.path);
|
|
6027
|
-
const abs =
|
|
6889
|
+
const abs = resolve8(absRoot, b.path);
|
|
6028
6890
|
if (!existsSync9(abs)) {
|
|
6029
6891
|
snapshots.push({ path: b.path, prevContent: null });
|
|
6030
6892
|
continue;
|
|
@@ -6038,9 +6900,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
|
|
|
6038
6900
|
return snapshots;
|
|
6039
6901
|
}
|
|
6040
6902
|
function restoreSnapshots(snapshots, rootDir) {
|
|
6041
|
-
const absRoot =
|
|
6903
|
+
const absRoot = resolve8(rootDir);
|
|
6042
6904
|
return snapshots.map((snap) => {
|
|
6043
|
-
const abs =
|
|
6905
|
+
const abs = resolve8(absRoot, snap.path);
|
|
6044
6906
|
if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
|
|
6045
6907
|
return {
|
|
6046
6908
|
path: snap.path,
|
|
@@ -6075,7 +6937,7 @@ function sep() {
|
|
|
6075
6937
|
// src/code/prompt.ts
|
|
6076
6938
|
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
6077
6939
|
import { join as join9 } from "path";
|
|
6078
|
-
var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files,
|
|
6940
|
+
var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, list_directory, directory_tree, search_files, search_content, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell.
|
|
6079
6941
|
|
|
6080
6942
|
# Cite or shut up \u2014 non-negotiable
|
|
6081
6943
|
|
|
@@ -6116,15 +6978,17 @@ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit con
|
|
|
6116
6978
|
- You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
|
|
6117
6979
|
|
|
6118
6980
|
|
|
6119
|
-
# Delegating to subagents via Skills
|
|
6981
|
+
# Delegating to subagents via Skills
|
|
6982
|
+
|
|
6983
|
+
The pinned Skills index below lists playbooks you can invoke with \`run_skill\`. Entries tagged \`[\u{1F9EC} subagent]\` spawn an **isolated subagent** \u2014 a fresh child loop that runs the playbook in its own context and returns only the final answer. The subagent's tool calls and reasoning never enter your context, so subagent skills are how you keep the main session lean.
|
|
6120
6984
|
|
|
6121
|
-
|
|
6985
|
+
**When you call \`run_skill\`, the \`name\` is ONLY the identifier before the tag** \u2014 e.g. \`run_skill({ name: "explore", arguments: "..." })\`, NOT \`"[\u{1F9EC} subagent] explore"\` and NOT \`"explore [\u{1F9EC} subagent]"\`. The tag is display sugar; the name argument is just the bare identifier.
|
|
6122
6986
|
|
|
6123
6987
|
Two built-ins ship by default:
|
|
6124
|
-
-
|
|
6125
|
-
-
|
|
6988
|
+
- **explore** \`[\u{1F9EC} subagent]\` \u2014 read-only investigation across the codebase. Use when the user says things like "find all places that...", "how does X work across the project", "survey the code for Y". Pass \`arguments\` describing the concrete question.
|
|
6989
|
+
- **research** \`[\u{1F9EC} subagent]\` \u2014 combines web search + code reading. Use for "is X supported by lib Y", "what's the canonical way to Z", "compare our impl to the spec".
|
|
6126
6990
|
|
|
6127
|
-
When to delegate (call \`run_skill\` with a
|
|
6991
|
+
When to delegate (call \`run_skill\` with a subagent skill):
|
|
6128
6992
|
- The task would otherwise need >5 file reads or searches.
|
|
6129
6993
|
- You only need the conclusion, not the exploration trail.
|
|
6130
6994
|
- The work is self-contained (you can describe it in one paragraph).
|
|
@@ -6147,6 +7011,15 @@ In those cases, use tools to gather what you need, then reply in prose. No SEARC
|
|
|
6147
7011
|
|
|
6148
7012
|
When you do propose edits, the user will review them and decide whether to \`/apply\` or \`/discard\`. Don't assume they'll accept \u2014 write as if each edit will be audited, because it will.
|
|
6149
7013
|
|
|
7014
|
+
Reasonix runs an **edit gate**. The user's current mode (\`review\` or \`auto\`) decides what happens to your writes; you DO NOT see which mode is active, and you SHOULD NOT ask. Write the same way in both cases.
|
|
7015
|
+
|
|
7016
|
+
- In \`auto\` mode \`edit_file\` / \`write_file\` calls land on disk immediately with an undo window \u2014 you'll get the normal "edit blocks: 1/1 applied" style response.
|
|
7017
|
+
- In \`review\` mode EACH \`edit_file\` / \`write_file\` call pauses tool dispatch while the user decides. You'll get one of these responses:
|
|
7018
|
+
- \`"edit blocks: 1/1 applied"\` \u2014 user approved it. Continue as normal.
|
|
7019
|
+
- \`"User rejected this edit to <path>. Don't retry the same SEARCH/REPLACE\u2026"\` \u2014 user said no to THIS specific edit. Do NOT re-emit the same block, do NOT switch tools to sneak it past the gate (write_file \u2192 edit_file, or text-form SEARCH/REPLACE). Either take a clearly different approach or stop and ask the user what they want instead.
|
|
7020
|
+
- Text-form SEARCH/REPLACE blocks in your assistant reply queue for end-of-turn /apply \u2014 same "don't retry on rejection" rule.
|
|
7021
|
+
- If the user presses Esc mid-prompt the whole turn is aborted; you won't get another tool response. Don't keep spamming tool calls after an abort.
|
|
7022
|
+
|
|
6150
7023
|
# Editing files
|
|
6151
7024
|
|
|
6152
7025
|
When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
|
|
@@ -6187,11 +7060,49 @@ Two different rules depending on which tool:
|
|
|
6187
7060
|
- **Filesystem tools** (\`read_file\`, \`list_directory\`, \`search_files\`, \`edit_file\`, etc.): paths are sandbox-relative. \`/\` means the project root, \`/src/foo.ts\` means \`<project>/src/foo.ts\`. Both relative (\`src/foo.ts\`) and POSIX-absolute (\`/src/foo.ts\`) forms work.
|
|
6188
7061
|
- **\`run_command\`**: the command runs in a real OS shell with cwd pinned to the project root. Paths inside the shell command are interpreted by THAT shell, not by us. **Never use leading \`/\` in run_command arguments** \u2014 Windows treats \`/tests\` as drive-root \`F:\\tests\` (non-existent), POSIX shells treat it as filesystem root. Use plain relative paths (\`tests\`, \`./tests\`, \`src/loop.ts\`) instead.
|
|
6189
7062
|
|
|
7063
|
+
# Foreground vs. background commands
|
|
7064
|
+
|
|
7065
|
+
You have TWO tools for running shell commands, and picking the right one is non-negotiable:
|
|
7066
|
+
|
|
7067
|
+
- \`run_command\` \u2014 blocks until the process exits. Use for: **tests, builds, lints, typechecks, git operations, one-shot scripts**. Anything that naturally returns in under a minute.
|
|
7068
|
+
- \`run_background\` \u2014 spawns and detaches after a brief startup window. Use for: **dev servers, watchers, any command with "dev" / "serve" / "watch" / "start" in the name**. Examples: \`npm run dev\`, \`pnpm dev\`, \`yarn start\`, \`vite\`, \`next dev\`, \`uvicorn app:app --reload\`, \`flask run\`, \`python -m http.server\`, \`cargo watch\`, \`tsc --watch\`, \`webpack serve\`.
|
|
7069
|
+
|
|
7070
|
+
**Never use run_command for a dev server.** It will block for 60s, time out, and the user will see a frozen tool call while the server was actually running fine. Always \`run_background\`, then \`job_output\` to peek at the logs when you need to verify something.
|
|
7071
|
+
|
|
7072
|
+
After \`run_background\`, tools available to you:
|
|
7073
|
+
- \`job_output(jobId, tailLines?)\` \u2014 read recent logs to verify startup / debug errors.
|
|
7074
|
+
- \`list_jobs\` \u2014 see every job this session (running + exited).
|
|
7075
|
+
- \`stop_job(jobId)\` \u2014 SIGTERM \u2192 SIGKILL after grace. Stop before switching port / config.
|
|
7076
|
+
|
|
7077
|
+
Don't re-start an already-running dev server \u2014 call \`list_jobs\` first when in doubt.
|
|
7078
|
+
|
|
7079
|
+
# Scope discipline on "run it" / "start it" requests
|
|
7080
|
+
|
|
7081
|
+
When the user's request is to **run / start / launch / serve / boot up** something, your job is ONLY:
|
|
7082
|
+
|
|
7083
|
+
1. Start it (\`run_background\` for dev servers, \`run_command\` for one-shots).
|
|
7084
|
+
2. Verify it came up (read a ready signal via \`job_output\`, or fetch the URL with \`web_fetch\` if they want you to confirm).
|
|
7085
|
+
3. Report what's running, where (URL / port / pid), and STOP.
|
|
7086
|
+
|
|
7087
|
+
Do NOT, in the same turn:
|
|
7088
|
+
- Run \`tsc\` / type-checkers / linters unless the user asked for it.
|
|
7089
|
+
- Scan for bugs to "proactively" fix. The page rendering is success.
|
|
7090
|
+
- Clean up unused imports, dead code, or refactor "while you're here."
|
|
7091
|
+
- Edit files to improve anything the user didn't mention.
|
|
7092
|
+
|
|
7093
|
+
If you notice an obvious issue, MENTION it in one sentence and wait for the user to say "fix it." The cost of over-eagerness is real: you burn tokens, make surprise edits the user didn't want, and chain into cascading "fix the new error I just introduced" loops. The storm-breaker will cut you off, but the user still sees the mess.
|
|
7094
|
+
|
|
7095
|
+
"It works" is the end state. Resist the urge to polish.
|
|
7096
|
+
|
|
6190
7097
|
# Style
|
|
6191
7098
|
|
|
6192
7099
|
- Show edits; don't narrate them in prose. "Here's the fix:" is enough.
|
|
6193
7100
|
- One short paragraph explaining *why*, then the blocks.
|
|
6194
7101
|
- If you need to explore first (list / read / search), do it with tool calls before writing any prose \u2014 silence while exploring is fine.
|
|
7102
|
+
|
|
7103
|
+
${ESCALATION_CONTRACT}
|
|
7104
|
+
|
|
7105
|
+
${TUI_FORMATTING_RULES}
|
|
6195
7106
|
`;
|
|
6196
7107
|
function codeSystemPrompt(rootDir) {
|
|
6197
7108
|
const withMemory = applyMemoryStack(CODE_SYSTEM_PROMPT, rootDir);
|