reasonix 0.4.6 → 0.4.12
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/index.js +1021 -162
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +194 -22
- package/dist/index.js +665 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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((resolve4, reject) => {
|
|
51
|
+
const timer = setTimeout(resolve4, ms);
|
|
52
52
|
if (signal) {
|
|
53
53
|
const onAbort = () => {
|
|
54
54
|
clearTimeout(timer);
|
|
@@ -133,6 +133,27 @@ var DeepSeekClient = class {
|
|
|
133
133
|
if (opts.responseFormat) payload.response_format = opts.responseFormat;
|
|
134
134
|
return payload;
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Fetch the current DeepSeek account balance. Separate endpoint
|
|
138
|
+
* from chat completions, no billing impact. Returns null on any
|
|
139
|
+
* network/auth failure so callers can gate the balance display
|
|
140
|
+
* without a hard error — the rest of the session works regardless.
|
|
141
|
+
*/
|
|
142
|
+
async getBalance(opts = {}) {
|
|
143
|
+
try {
|
|
144
|
+
const resp = await this._fetch(`${this.baseUrl}/user/balance`, {
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
147
|
+
signal: opts.signal
|
|
148
|
+
});
|
|
149
|
+
if (!resp.ok) return null;
|
|
150
|
+
const data = await resp.json();
|
|
151
|
+
if (!data || !Array.isArray(data.balance_infos)) return null;
|
|
152
|
+
return data;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
136
157
|
async chat(opts) {
|
|
137
158
|
const ctrl = new AbortController();
|
|
138
159
|
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
@@ -279,8 +300,9 @@ Constraints:
|
|
|
279
300
|
- Each item is plain text, at most {maxItemLen} characters, no markdown.
|
|
280
301
|
- Write in the same language as the trace (Chinese in \u2192 Chinese out, etc.).
|
|
281
302
|
- Do not quote back the trace; write short, specific phrases.`;
|
|
282
|
-
async function harvest(reasoningContent, client, options = {}) {
|
|
303
|
+
async function harvest(reasoningContent, client, options = {}, signal) {
|
|
283
304
|
if (!client || !reasoningContent) return emptyPlanState();
|
|
305
|
+
if (signal?.aborted) return emptyPlanState();
|
|
284
306
|
const minLen = options.minReasoningLen ?? 40;
|
|
285
307
|
const trimmed = reasoningContent.trim();
|
|
286
308
|
if (trimmed.length < minLen) return emptyPlanState();
|
|
@@ -300,7 +322,8 @@ async function harvest(reasoningContent, client, options = {}) {
|
|
|
300
322
|
],
|
|
301
323
|
responseFormat: { type: "json_object" },
|
|
302
324
|
temperature: 0,
|
|
303
|
-
maxTokens: 600
|
|
325
|
+
maxTokens: 600,
|
|
326
|
+
signal
|
|
304
327
|
});
|
|
305
328
|
return parsePlanState(resp.content, maxItems, maxItemLen);
|
|
306
329
|
} catch {
|
|
@@ -514,7 +537,7 @@ var ToolRegistry = class {
|
|
|
514
537
|
}
|
|
515
538
|
}));
|
|
516
539
|
}
|
|
517
|
-
async dispatch(name, argumentsRaw) {
|
|
540
|
+
async dispatch(name, argumentsRaw, opts = {}) {
|
|
518
541
|
const tool = this._tools.get(name);
|
|
519
542
|
if (!tool) {
|
|
520
543
|
return JSON.stringify({ error: `unknown tool: ${name}` });
|
|
@@ -531,7 +554,7 @@ var ToolRegistry = class {
|
|
|
531
554
|
args = nestArguments(args);
|
|
532
555
|
}
|
|
533
556
|
try {
|
|
534
|
-
const result = await tool.fn(args);
|
|
557
|
+
const result = await tool.fn(args, { signal: opts.signal });
|
|
535
558
|
return typeof result === "string" ? result : JSON.stringify(result);
|
|
536
559
|
} catch (err) {
|
|
537
560
|
return JSON.stringify({
|
|
@@ -565,8 +588,20 @@ async function bridgeMcpTools(client, opts = {}) {
|
|
|
565
588
|
name: registeredName,
|
|
566
589
|
description: mcpTool.description ?? "",
|
|
567
590
|
parameters: mcpTool.inputSchema,
|
|
568
|
-
fn: async (args) => {
|
|
569
|
-
const toolResult = await client.callTool(mcpTool.name, args
|
|
591
|
+
fn: async (args, ctx) => {
|
|
592
|
+
const toolResult = await client.callTool(mcpTool.name, args, {
|
|
593
|
+
// Forward server-side progress frames to the bridge caller,
|
|
594
|
+
// tagged with the registered name so multi-server UIs can
|
|
595
|
+
// disambiguate. No-op when `onProgress` isn't configured —
|
|
596
|
+
// the client then also omits the _meta.progressToken and
|
|
597
|
+
// the server won't emit progress.
|
|
598
|
+
onProgress: opts.onProgress ? (info) => opts.onProgress({ toolName: registeredName, ...info }) : void 0,
|
|
599
|
+
// Thread the tool-dispatch AbortSignal all the way down to
|
|
600
|
+
// the MCP request so Esc truly cancels in flight — the
|
|
601
|
+
// client will emit notifications/cancelled AND reject the
|
|
602
|
+
// pending promise immediately, no "wait for subprocess".
|
|
603
|
+
signal: ctx?.signal
|
|
604
|
+
});
|
|
570
605
|
return flattenMcpResult(toolResult, { maxChars: maxResultChars });
|
|
571
606
|
}
|
|
572
607
|
});
|
|
@@ -1077,6 +1112,16 @@ function costUsd(model, usage) {
|
|
|
1077
1112
|
if (!p) return 0;
|
|
1078
1113
|
return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss + usage.completionTokens * p.output) / 1e6;
|
|
1079
1114
|
}
|
|
1115
|
+
function inputCostUsd(model, usage) {
|
|
1116
|
+
const p = DEEPSEEK_PRICING[model];
|
|
1117
|
+
if (!p) return 0;
|
|
1118
|
+
return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss) / 1e6;
|
|
1119
|
+
}
|
|
1120
|
+
function outputCostUsd(model, usage) {
|
|
1121
|
+
const p = DEEPSEEK_PRICING[model];
|
|
1122
|
+
if (!p) return 0;
|
|
1123
|
+
return usage.completionTokens * p.output / 1e6;
|
|
1124
|
+
}
|
|
1080
1125
|
function claudeEquivalentCost(usage) {
|
|
1081
1126
|
return (usage.promptTokens * CLAUDE_SONNET_PRICING.input + usage.completionTokens * CLAUDE_SONNET_PRICING.output) / 1e6;
|
|
1082
1127
|
}
|
|
@@ -1104,6 +1149,12 @@ var SessionStats = class {
|
|
|
1104
1149
|
const c = this.totalClaudeEquivalent;
|
|
1105
1150
|
return c > 0 ? 1 - this.totalCost / c : 0;
|
|
1106
1151
|
}
|
|
1152
|
+
get totalInputCost() {
|
|
1153
|
+
return this.turns.reduce((sum, t) => sum + inputCostUsd(t.model, t.usage), 0);
|
|
1154
|
+
}
|
|
1155
|
+
get totalOutputCost() {
|
|
1156
|
+
return this.turns.reduce((sum, t) => sum + outputCostUsd(t.model, t.usage), 0);
|
|
1157
|
+
}
|
|
1107
1158
|
get aggregateCacheHitRatio() {
|
|
1108
1159
|
let hit = 0;
|
|
1109
1160
|
let miss = 0;
|
|
@@ -1119,6 +1170,8 @@ var SessionStats = class {
|
|
|
1119
1170
|
return {
|
|
1120
1171
|
turns: this.turns.length,
|
|
1121
1172
|
totalCostUsd: round(this.totalCost, 6),
|
|
1173
|
+
totalInputCostUsd: round(this.totalInputCost, 6),
|
|
1174
|
+
totalOutputCostUsd: round(this.totalOutputCost, 6),
|
|
1122
1175
|
claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
|
|
1123
1176
|
savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
|
|
1124
1177
|
cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
|
|
@@ -1155,11 +1208,13 @@ var CacheFirstLoop = class {
|
|
|
1155
1208
|
_turn = 0;
|
|
1156
1209
|
_streamPreference;
|
|
1157
1210
|
/**
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1211
|
+
* AbortController per active turn. Threaded through the DeepSeek
|
|
1212
|
+
* HTTP calls AND every tool dispatch so Esc actually cancels the
|
|
1213
|
+
* in-flight network/subprocess work — not "we'll get to it after
|
|
1214
|
+
* the current call finishes." Re-created at the start of each
|
|
1215
|
+
* `step()` (the prior turn's signal has already fired).
|
|
1161
1216
|
*/
|
|
1162
|
-
|
|
1217
|
+
_turnAbort = new AbortController();
|
|
1163
1218
|
constructor(opts) {
|
|
1164
1219
|
this.client = opts.client;
|
|
1165
1220
|
this.prefix = opts.prefix;
|
|
@@ -1191,8 +1246,12 @@ var CacheFirstLoop = class {
|
|
|
1191
1246
|
for (const msg of messages) this.log.append(msg);
|
|
1192
1247
|
this.resumedMessageCount = messages.length;
|
|
1193
1248
|
if (healedCount > 0) {
|
|
1249
|
+
try {
|
|
1250
|
+
rewriteSession(this.sessionName, messages);
|
|
1251
|
+
} catch {
|
|
1252
|
+
}
|
|
1194
1253
|
process.stderr.write(
|
|
1195
|
-
`\u25B8 session "${this.sessionName}": healed ${healedCount}
|
|
1254
|
+
`\u25B8 session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${healedFrom > 0 ? ` (was ${healedFrom.toLocaleString()} chars oversized)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.
|
|
1196
1255
|
`
|
|
1197
1256
|
);
|
|
1198
1257
|
}
|
|
@@ -1213,7 +1272,7 @@ var CacheFirstLoop = class {
|
|
|
1213
1272
|
*/
|
|
1214
1273
|
compact(tightCapChars = 4e3) {
|
|
1215
1274
|
const before = this.log.toMessages();
|
|
1216
|
-
const { messages, healedCount, healedFrom } =
|
|
1275
|
+
const { messages, healedCount, healedFrom } = shrinkOversizedToolResults(before, tightCapChars);
|
|
1217
1276
|
const afterBytes = messages.filter((m) => m.role === "tool").reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
|
|
1218
1277
|
const charsSaved = healedFrom - afterBytes;
|
|
1219
1278
|
if (healedCount > 0) {
|
|
@@ -1236,6 +1295,29 @@ var CacheFirstLoop = class {
|
|
|
1236
1295
|
}
|
|
1237
1296
|
}
|
|
1238
1297
|
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Start a fresh conversation WITHOUT exiting. Drops every message
|
|
1300
|
+
* in the in-memory log AND rewrites the session file to empty so
|
|
1301
|
+
* a resume won't re-hydrate the old turns. Unlike `/forget`, which
|
|
1302
|
+
* deletes the session entirely, this keeps the session name and
|
|
1303
|
+
* config intact — it's the "new chat" button.
|
|
1304
|
+
*
|
|
1305
|
+
* The immutable prefix (system prompt + tool specs) is preserved
|
|
1306
|
+
* — that's the cache-first invariant, not part of the conversation.
|
|
1307
|
+
* Returns the number of messages dropped so the UI can show it.
|
|
1308
|
+
*/
|
|
1309
|
+
clearLog() {
|
|
1310
|
+
const dropped = this.log.length;
|
|
1311
|
+
this.log.compactInPlace([]);
|
|
1312
|
+
if (this.sessionName) {
|
|
1313
|
+
try {
|
|
1314
|
+
rewriteSession(this.sessionName, []);
|
|
1315
|
+
} catch {
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
this.scratch.reset();
|
|
1319
|
+
return { dropped };
|
|
1320
|
+
}
|
|
1239
1321
|
/**
|
|
1240
1322
|
* Reconfigure model/harvest/branch/stream mid-session. The loop's log,
|
|
1241
1323
|
* scratch, and stats are preserved — only the per-turn behavior changes.
|
|
@@ -1267,19 +1349,21 @@ var CacheFirstLoop = class {
|
|
|
1267
1349
|
this.stream = this.branchEnabled ? false : this._streamPreference;
|
|
1268
1350
|
}
|
|
1269
1351
|
buildMessages(pendingUser) {
|
|
1270
|
-
const
|
|
1352
|
+
const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
|
|
1353
|
+
const msgs = [...this.prefix.toMessages(), ...healed.messages];
|
|
1271
1354
|
if (pendingUser !== null) msgs.push({ role: "user", content: pendingUser });
|
|
1272
1355
|
return msgs;
|
|
1273
1356
|
}
|
|
1274
1357
|
/**
|
|
1275
|
-
* Signal the currently-running {@link step}
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1358
|
+
* Signal the currently-running {@link step} to stop **now**. Cancels
|
|
1359
|
+
* the in-flight network request (DeepSeek HTTP/SSE) AND any tool call
|
|
1360
|
+
* currently dispatching (MCP `notifications/cancelled` + promise
|
|
1361
|
+
* reject). The loop itself also sees `signal.aborted` at each
|
|
1362
|
+
* iteration boundary and exits quickly instead of looping again.
|
|
1363
|
+
* Called by the TUI on Esc.
|
|
1280
1364
|
*/
|
|
1281
1365
|
abort() {
|
|
1282
|
-
this.
|
|
1366
|
+
this._turnAbort.abort();
|
|
1283
1367
|
}
|
|
1284
1368
|
/**
|
|
1285
1369
|
* Drop everything in the log after (and including) the most recent
|
|
@@ -1317,13 +1401,14 @@ var CacheFirstLoop = class {
|
|
|
1317
1401
|
async *step(userInput) {
|
|
1318
1402
|
this._turn++;
|
|
1319
1403
|
this.scratch.reset();
|
|
1320
|
-
this.
|
|
1404
|
+
this._turnAbort = new AbortController();
|
|
1405
|
+
const signal = this._turnAbort.signal;
|
|
1321
1406
|
let pendingUser = userInput;
|
|
1322
1407
|
const toolSpecs = this.prefix.tools();
|
|
1323
1408
|
const warnAt = Math.max(1, Math.floor(this.maxToolIters * 0.7));
|
|
1324
1409
|
let warnedForIterBudget = false;
|
|
1325
1410
|
for (let iter = 0; iter < this.maxToolIters; iter++) {
|
|
1326
|
-
if (
|
|
1411
|
+
if (signal.aborted) {
|
|
1327
1412
|
yield {
|
|
1328
1413
|
turn: this._turn,
|
|
1329
1414
|
role: "warning",
|
|
@@ -1340,6 +1425,13 @@ var CacheFirstLoop = class {
|
|
|
1340
1425
|
yield { turn: this._turn, role: "done", content: stoppedMsg };
|
|
1341
1426
|
return;
|
|
1342
1427
|
}
|
|
1428
|
+
if (iter > 0) {
|
|
1429
|
+
yield {
|
|
1430
|
+
turn: this._turn,
|
|
1431
|
+
role: "status",
|
|
1432
|
+
content: "tool result uploaded \xB7 model thinking before next response\u2026"
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1343
1435
|
if (!warnedForIterBudget && iter >= warnAt) {
|
|
1344
1436
|
warnedForIterBudget = true;
|
|
1345
1437
|
yield {
|
|
@@ -1386,7 +1478,8 @@ var CacheFirstLoop = class {
|
|
|
1386
1478
|
{
|
|
1387
1479
|
model: this.model,
|
|
1388
1480
|
messages,
|
|
1389
|
-
tools: toolSpecs.length ? toolSpecs : void 0
|
|
1481
|
+
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
1482
|
+
signal
|
|
1390
1483
|
},
|
|
1391
1484
|
{
|
|
1392
1485
|
...this.branchOptions,
|
|
@@ -1395,8 +1488,8 @@ var CacheFirstLoop = class {
|
|
|
1395
1488
|
}
|
|
1396
1489
|
);
|
|
1397
1490
|
for (let k = 0; k < budget; k++) {
|
|
1398
|
-
const sample = queue.shift() ?? await new Promise((
|
|
1399
|
-
waiter =
|
|
1491
|
+
const sample = queue.shift() ?? await new Promise((resolve4) => {
|
|
1492
|
+
waiter = resolve4;
|
|
1400
1493
|
});
|
|
1401
1494
|
yield {
|
|
1402
1495
|
turn: this._turn,
|
|
@@ -1436,7 +1529,8 @@ var CacheFirstLoop = class {
|
|
|
1436
1529
|
for await (const chunk of this.client.stream({
|
|
1437
1530
|
model: this.model,
|
|
1438
1531
|
messages,
|
|
1439
|
-
tools: toolSpecs.length ? toolSpecs : void 0
|
|
1532
|
+
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
1533
|
+
signal
|
|
1440
1534
|
})) {
|
|
1441
1535
|
if (chunk.contentDelta) {
|
|
1442
1536
|
assistantContent += chunk.contentDelta;
|
|
@@ -1475,7 +1569,8 @@ var CacheFirstLoop = class {
|
|
|
1475
1569
|
const resp = await this.client.chat({
|
|
1476
1570
|
model: this.model,
|
|
1477
1571
|
messages,
|
|
1478
|
-
tools: toolSpecs.length ? toolSpecs : void 0
|
|
1572
|
+
tools: toolSpecs.length ? toolSpecs : void 0,
|
|
1573
|
+
signal
|
|
1479
1574
|
});
|
|
1480
1575
|
assistantContent = resp.content;
|
|
1481
1576
|
reasoningContent = resp.reasoningContent ?? "";
|
|
@@ -1497,7 +1592,14 @@ var CacheFirstLoop = class {
|
|
|
1497
1592
|
pendingUser = null;
|
|
1498
1593
|
}
|
|
1499
1594
|
this.scratch.reasoning = reasoningContent || null;
|
|
1500
|
-
|
|
1595
|
+
if (!preHarvestedPlanState && this.harvestEnabled && (reasoningContent?.trim().length ?? 0) >= 40) {
|
|
1596
|
+
yield {
|
|
1597
|
+
turn: this._turn,
|
|
1598
|
+
role: "status",
|
|
1599
|
+
content: "extracting plan state from reasoning\u2026"
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
const planState = preHarvestedPlanState ? preHarvestedPlanState : this.harvestEnabled ? await harvest(reasoningContent || null, this.client, this.harvestOptions, signal) : emptyPlanState();
|
|
1501
1603
|
const { calls: repairedCalls, report } = this.repair.process(
|
|
1502
1604
|
toolCalls,
|
|
1503
1605
|
reasoningContent || null,
|
|
@@ -1519,15 +1621,38 @@ var CacheFirstLoop = class {
|
|
|
1519
1621
|
}
|
|
1520
1622
|
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
|
|
1521
1623
|
if (usage && usage.promptTokens / ctxMax > 0.8) {
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1624
|
+
const before = usage.promptTokens;
|
|
1625
|
+
const compactResult = this.compact(4e3);
|
|
1626
|
+
if (compactResult.healedCount > 0) {
|
|
1627
|
+
const approxSaved = Math.round(compactResult.charsSaved / 4);
|
|
1628
|
+
const after = before - approxSaved;
|
|
1629
|
+
yield {
|
|
1630
|
+
turn: this._turn,
|
|
1631
|
+
role: "warning",
|
|
1632
|
+
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} \u2014 auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ~${approxSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`
|
|
1633
|
+
};
|
|
1634
|
+
} else {
|
|
1635
|
+
yield {
|
|
1636
|
+
turn: this._turn,
|
|
1637
|
+
role: "warning",
|
|
1638
|
+
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
|
|
1639
|
+
before / ctxMax * 100
|
|
1640
|
+
)}%) \u2014 nothing to auto-compact. Forcing summary from what was gathered.`
|
|
1641
|
+
};
|
|
1642
|
+
const tail = this.log.entries[this.log.entries.length - 1];
|
|
1643
|
+
if (tail && tail.role === "assistant" && Array.isArray(tail.tool_calls) && tail.tool_calls.length > 0) {
|
|
1644
|
+
const kept = this.log.entries.slice(0, -1);
|
|
1645
|
+
this.log.compactInPlace([...kept]);
|
|
1646
|
+
if (this.sessionName) {
|
|
1647
|
+
try {
|
|
1648
|
+
rewriteSession(this.sessionName, kept);
|
|
1649
|
+
} catch {
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1531
1656
|
}
|
|
1532
1657
|
for (const call of repairedCalls) {
|
|
1533
1658
|
const name = call.function?.name ?? "";
|
|
@@ -1539,7 +1664,7 @@ var CacheFirstLoop = class {
|
|
|
1539
1664
|
toolName: name,
|
|
1540
1665
|
toolArgs: args
|
|
1541
1666
|
};
|
|
1542
|
-
const result = await this.tools.dispatch(name, args);
|
|
1667
|
+
const result = await this.tools.dispatch(name, args, { signal });
|
|
1543
1668
|
this.appendAndPersist({
|
|
1544
1669
|
role: "tool",
|
|
1545
1670
|
tool_call_id: call.id ?? "",
|
|
@@ -1559,6 +1684,11 @@ var CacheFirstLoop = class {
|
|
|
1559
1684
|
}
|
|
1560
1685
|
async *forceSummaryAfterIterLimit(opts = { reason: "budget" }) {
|
|
1561
1686
|
try {
|
|
1687
|
+
yield {
|
|
1688
|
+
turn: this._turn,
|
|
1689
|
+
role: "status",
|
|
1690
|
+
content: "summarizing what was gathered\u2026"
|
|
1691
|
+
};
|
|
1562
1692
|
const messages = this.buildMessages(null);
|
|
1563
1693
|
messages.push({
|
|
1564
1694
|
role: "user",
|
|
@@ -1566,8 +1696,9 @@ var CacheFirstLoop = class {
|
|
|
1566
1696
|
});
|
|
1567
1697
|
const resp = await this.client.chat({
|
|
1568
1698
|
model: this.model,
|
|
1569
|
-
messages
|
|
1699
|
+
messages,
|
|
1570
1700
|
// no tools → model is forced to answer in text
|
|
1701
|
+
signal: this._turnAbort.signal
|
|
1571
1702
|
});
|
|
1572
1703
|
const rawContent = resp.content?.trim() ?? "";
|
|
1573
1704
|
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
|
@@ -1640,7 +1771,7 @@ function summarizeBranch(chosen, samples) {
|
|
|
1640
1771
|
temperatures: samples.map((s) => s.temperature)
|
|
1641
1772
|
};
|
|
1642
1773
|
}
|
|
1643
|
-
function
|
|
1774
|
+
function shrinkOversizedToolResults(messages, maxChars) {
|
|
1644
1775
|
let healedCount = 0;
|
|
1645
1776
|
let healedFrom = 0;
|
|
1646
1777
|
const out = messages.map((msg) => {
|
|
@@ -1653,6 +1784,51 @@ function healLoadedMessages(messages, maxChars) {
|
|
|
1653
1784
|
});
|
|
1654
1785
|
return { messages: out, healedCount, healedFrom };
|
|
1655
1786
|
}
|
|
1787
|
+
function healLoadedMessages(messages, maxChars) {
|
|
1788
|
+
const shrunk = shrinkOversizedToolResults(messages, maxChars);
|
|
1789
|
+
let healedCount = shrunk.healedCount;
|
|
1790
|
+
const out = [];
|
|
1791
|
+
const openCallIds = /* @__PURE__ */ new Set();
|
|
1792
|
+
let droppedAssistantCalls = 0;
|
|
1793
|
+
let droppedStrayTools = 0;
|
|
1794
|
+
for (let i = 0; i < shrunk.messages.length; i++) {
|
|
1795
|
+
const msg = shrunk.messages[i];
|
|
1796
|
+
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
1797
|
+
const needed = /* @__PURE__ */ new Set();
|
|
1798
|
+
for (const call of msg.tool_calls) {
|
|
1799
|
+
if (call?.id) needed.add(call.id);
|
|
1800
|
+
}
|
|
1801
|
+
const candidates = [];
|
|
1802
|
+
let j = i + 1;
|
|
1803
|
+
while (j < shrunk.messages.length && needed.size > 0) {
|
|
1804
|
+
const nxt = shrunk.messages[j];
|
|
1805
|
+
if (nxt.role !== "tool") break;
|
|
1806
|
+
const id = nxt.tool_call_id ?? "";
|
|
1807
|
+
if (!needed.has(id)) break;
|
|
1808
|
+
needed.delete(id);
|
|
1809
|
+
candidates.push(nxt);
|
|
1810
|
+
j++;
|
|
1811
|
+
}
|
|
1812
|
+
if (needed.size === 0) {
|
|
1813
|
+
out.push(msg);
|
|
1814
|
+
for (const r of candidates) out.push(r);
|
|
1815
|
+
i = j - 1;
|
|
1816
|
+
} else {
|
|
1817
|
+
droppedAssistantCalls += 1;
|
|
1818
|
+
droppedStrayTools += candidates.length;
|
|
1819
|
+
i = j - 1;
|
|
1820
|
+
}
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
if (msg.role === "tool") {
|
|
1824
|
+
droppedStrayTools += 1;
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
out.push(msg);
|
|
1828
|
+
}
|
|
1829
|
+
healedCount += droppedAssistantCalls + droppedStrayTools;
|
|
1830
|
+
return { messages: out, healedCount, healedFrom: shrunk.healedFrom };
|
|
1831
|
+
}
|
|
1656
1832
|
function formatLoopError(err) {
|
|
1657
1833
|
const msg = err.message ?? "";
|
|
1658
1834
|
if (msg.includes("maximum context length")) {
|
|
@@ -1663,13 +1839,348 @@ function formatLoopError(err) {
|
|
|
1663
1839
|
return msg;
|
|
1664
1840
|
}
|
|
1665
1841
|
|
|
1842
|
+
// src/tools/filesystem.ts
|
|
1843
|
+
import { promises as fs } from "fs";
|
|
1844
|
+
import * as pathMod from "path";
|
|
1845
|
+
var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
1846
|
+
var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
|
|
1847
|
+
function registerFilesystemTools(registry, opts) {
|
|
1848
|
+
const rootDir = pathMod.resolve(opts.rootDir);
|
|
1849
|
+
const allowWriting = opts.allowWriting !== false;
|
|
1850
|
+
const maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
|
|
1851
|
+
const maxListBytes = opts.maxListBytes ?? DEFAULT_MAX_LIST_BYTES;
|
|
1852
|
+
const safePath = (raw) => {
|
|
1853
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
1854
|
+
throw new Error("path must be a non-empty string");
|
|
1855
|
+
}
|
|
1856
|
+
const resolved = pathMod.resolve(rootDir, raw);
|
|
1857
|
+
const normRoot = pathMod.resolve(rootDir);
|
|
1858
|
+
const rel = pathMod.relative(normRoot, resolved);
|
|
1859
|
+
if (rel.startsWith("..") || pathMod.isAbsolute(rel)) {
|
|
1860
|
+
throw new Error(`path escapes sandbox root (${normRoot}): ${raw}`);
|
|
1861
|
+
}
|
|
1862
|
+
return resolved;
|
|
1863
|
+
};
|
|
1864
|
+
registry.register({
|
|
1865
|
+
name: "read_file",
|
|
1866
|
+
description: "Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
|
|
1867
|
+
parameters: {
|
|
1868
|
+
type: "object",
|
|
1869
|
+
properties: {
|
|
1870
|
+
path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
|
|
1871
|
+
head: { type: "integer", description: "If set, return only the first N lines." },
|
|
1872
|
+
tail: { type: "integer", description: "If set, return only the last N lines." }
|
|
1873
|
+
},
|
|
1874
|
+
required: ["path"]
|
|
1875
|
+
},
|
|
1876
|
+
fn: async (args) => {
|
|
1877
|
+
const abs = safePath(args.path);
|
|
1878
|
+
const stat = await fs.stat(abs);
|
|
1879
|
+
if (stat.isDirectory()) {
|
|
1880
|
+
throw new Error(`not a file: ${args.path} (it's a directory)`);
|
|
1881
|
+
}
|
|
1882
|
+
const raw = await fs.readFile(abs);
|
|
1883
|
+
if (raw.length > maxReadBytes) {
|
|
1884
|
+
const head = raw.slice(0, maxReadBytes).toString("utf8");
|
|
1885
|
+
return `${head}
|
|
1886
|
+
|
|
1887
|
+
[\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
|
|
1888
|
+
}
|
|
1889
|
+
const text = raw.toString("utf8");
|
|
1890
|
+
if (typeof args.head === "number" && args.head > 0) {
|
|
1891
|
+
return text.split(/\r?\n/).slice(0, args.head).join("\n");
|
|
1892
|
+
}
|
|
1893
|
+
if (typeof args.tail === "number" && args.tail > 0) {
|
|
1894
|
+
let lines = text.split(/\r?\n/);
|
|
1895
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
|
|
1896
|
+
return lines.slice(Math.max(0, lines.length - args.tail)).join("\n");
|
|
1897
|
+
}
|
|
1898
|
+
return text;
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
registry.register({
|
|
1902
|
+
name: "list_directory",
|
|
1903
|
+
description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
|
|
1904
|
+
parameters: {
|
|
1905
|
+
type: "object",
|
|
1906
|
+
properties: {
|
|
1907
|
+
path: { type: "string", description: "Directory to list (default: root)." }
|
|
1908
|
+
}
|
|
1909
|
+
},
|
|
1910
|
+
fn: async (args) => {
|
|
1911
|
+
const abs = safePath(args.path ?? ".");
|
|
1912
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
1913
|
+
const lines = [];
|
|
1914
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1915
|
+
lines.push(e.isDirectory() ? `${e.name}/` : e.name);
|
|
1916
|
+
}
|
|
1917
|
+
return lines.join("\n") || "(empty directory)";
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
registry.register({
|
|
1921
|
+
name: "directory_tree",
|
|
1922
|
+
description: "Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
|
|
1923
|
+
parameters: {
|
|
1924
|
+
type: "object",
|
|
1925
|
+
properties: {
|
|
1926
|
+
path: { type: "string", description: "Root of the tree (default: sandbox root)." },
|
|
1927
|
+
maxDepth: { type: "integer", description: "Max recursion depth (default 4)." }
|
|
1928
|
+
}
|
|
1929
|
+
},
|
|
1930
|
+
fn: async (args) => {
|
|
1931
|
+
const startAbs = safePath(args.path ?? ".");
|
|
1932
|
+
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 4;
|
|
1933
|
+
const lines = [];
|
|
1934
|
+
let totalBytes = 0;
|
|
1935
|
+
let truncated = false;
|
|
1936
|
+
const walk2 = async (dir, depth) => {
|
|
1937
|
+
if (truncated) return;
|
|
1938
|
+
if (depth > maxDepth) return;
|
|
1939
|
+
let entries;
|
|
1940
|
+
try {
|
|
1941
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1942
|
+
} catch {
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1946
|
+
for (const e of entries) {
|
|
1947
|
+
if (truncated) return;
|
|
1948
|
+
const indent = " ".repeat(depth);
|
|
1949
|
+
const line = e.isDirectory() ? `${indent}${e.name}/` : `${indent}${e.name}`;
|
|
1950
|
+
totalBytes += line.length + 1;
|
|
1951
|
+
if (totalBytes > maxListBytes) {
|
|
1952
|
+
lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
|
|
1953
|
+
truncated = true;
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
lines.push(line);
|
|
1957
|
+
if (e.isDirectory()) {
|
|
1958
|
+
await walk2(pathMod.join(dir, e.name), depth + 1);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
await walk2(startAbs, 0);
|
|
1963
|
+
return lines.join("\n") || "(empty tree)";
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
registry.register({
|
|
1967
|
+
name: "search_files",
|
|
1968
|
+
description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
|
|
1969
|
+
parameters: {
|
|
1970
|
+
type: "object",
|
|
1971
|
+
properties: {
|
|
1972
|
+
path: { type: "string", description: "Directory to start the search at (default: root)." },
|
|
1973
|
+
pattern: {
|
|
1974
|
+
type: "string",
|
|
1975
|
+
description: "Substring (or regex) to match against filenames."
|
|
1976
|
+
}
|
|
1977
|
+
},
|
|
1978
|
+
required: ["pattern"]
|
|
1979
|
+
},
|
|
1980
|
+
fn: async (args) => {
|
|
1981
|
+
const startAbs = safePath(args.path ?? ".");
|
|
1982
|
+
const needle = args.pattern.toLowerCase();
|
|
1983
|
+
let re = null;
|
|
1984
|
+
try {
|
|
1985
|
+
re = new RegExp(args.pattern, "i");
|
|
1986
|
+
} catch {
|
|
1987
|
+
re = null;
|
|
1988
|
+
}
|
|
1989
|
+
const matches = [];
|
|
1990
|
+
let totalBytes = 0;
|
|
1991
|
+
const walk2 = async (dir) => {
|
|
1992
|
+
let entries;
|
|
1993
|
+
try {
|
|
1994
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1995
|
+
} catch {
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
for (const e of entries) {
|
|
1999
|
+
const full = pathMod.join(dir, e.name);
|
|
2000
|
+
const lower = e.name.toLowerCase();
|
|
2001
|
+
const hit = re ? re.test(e.name) : lower.includes(needle);
|
|
2002
|
+
if (hit) {
|
|
2003
|
+
const rel = pathMod.relative(rootDir, full);
|
|
2004
|
+
if (totalBytes + rel.length + 1 > maxListBytes) {
|
|
2005
|
+
matches.push("[\u2026 search truncated \u2014 refine pattern \u2026]");
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
matches.push(rel);
|
|
2009
|
+
totalBytes += rel.length + 1;
|
|
2010
|
+
}
|
|
2011
|
+
if (e.isDirectory()) await walk2(full);
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
await walk2(startAbs);
|
|
2015
|
+
return matches.length === 0 ? "(no matches)" : matches.join("\n");
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
registry.register({
|
|
2019
|
+
name: "get_file_info",
|
|
2020
|
+
description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
|
|
2021
|
+
parameters: {
|
|
2022
|
+
type: "object",
|
|
2023
|
+
properties: {
|
|
2024
|
+
path: { type: "string" }
|
|
2025
|
+
},
|
|
2026
|
+
required: ["path"]
|
|
2027
|
+
},
|
|
2028
|
+
fn: async (args) => {
|
|
2029
|
+
const abs = safePath(args.path);
|
|
2030
|
+
const st = await fs.lstat(abs);
|
|
2031
|
+
const type = st.isDirectory() ? "directory" : st.isSymbolicLink() ? "symlink" : "file";
|
|
2032
|
+
return JSON.stringify({
|
|
2033
|
+
type,
|
|
2034
|
+
size: st.size,
|
|
2035
|
+
mtime: st.mtime.toISOString()
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
if (!allowWriting) return registry;
|
|
2040
|
+
registry.register({
|
|
2041
|
+
name: "write_file",
|
|
2042
|
+
description: "Create or overwrite a file under the sandbox root with the given content. Parent directories are created as needed.",
|
|
2043
|
+
parameters: {
|
|
2044
|
+
type: "object",
|
|
2045
|
+
properties: {
|
|
2046
|
+
path: { type: "string" },
|
|
2047
|
+
content: { type: "string" }
|
|
2048
|
+
},
|
|
2049
|
+
required: ["path", "content"]
|
|
2050
|
+
},
|
|
2051
|
+
fn: async (args) => {
|
|
2052
|
+
const abs = safePath(args.path);
|
|
2053
|
+
await fs.mkdir(pathMod.dirname(abs), { recursive: true });
|
|
2054
|
+
await fs.writeFile(abs, args.content, "utf8");
|
|
2055
|
+
return `wrote ${args.content.length} chars to ${pathMod.relative(rootDir, abs)}`;
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
registry.register({
|
|
2059
|
+
name: "edit_file",
|
|
2060
|
+
description: "Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) \u2014 no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites. This flat-string shape replaces the `{oldText, newText}[]` JSON array form that previously triggered R1 DSML hallucinations.",
|
|
2061
|
+
parameters: {
|
|
2062
|
+
type: "object",
|
|
2063
|
+
properties: {
|
|
2064
|
+
path: { type: "string" },
|
|
2065
|
+
search: { type: "string", description: "Exact text to find (must be unique)." },
|
|
2066
|
+
replace: { type: "string", description: "Text to substitute in place of `search`." }
|
|
2067
|
+
},
|
|
2068
|
+
required: ["path", "search", "replace"]
|
|
2069
|
+
},
|
|
2070
|
+
fn: async (args) => {
|
|
2071
|
+
const abs = safePath(args.path);
|
|
2072
|
+
const before = await fs.readFile(abs, "utf8");
|
|
2073
|
+
if (args.search.length === 0) {
|
|
2074
|
+
throw new Error("edit_file: search cannot be empty");
|
|
2075
|
+
}
|
|
2076
|
+
const firstIdx = before.indexOf(args.search);
|
|
2077
|
+
if (firstIdx < 0) {
|
|
2078
|
+
throw new Error(`edit_file: search text not found in ${pathMod.relative(rootDir, abs)}`);
|
|
2079
|
+
}
|
|
2080
|
+
const nextIdx = before.indexOf(args.search, firstIdx + 1);
|
|
2081
|
+
if (nextIdx >= 0) {
|
|
2082
|
+
throw new Error(
|
|
2083
|
+
`edit_file: search text appears multiple times in ${pathMod.relative(rootDir, abs)} \u2014 include more context to disambiguate`
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
const after = before.slice(0, firstIdx) + args.replace + before.slice(firstIdx + args.search.length);
|
|
2087
|
+
await fs.writeFile(abs, after, "utf8");
|
|
2088
|
+
const rel = pathMod.relative(rootDir, abs);
|
|
2089
|
+
const header = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
|
|
2090
|
+
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
|
|
2091
|
+
const diff = renderEditDiff(args.search, args.replace, startLine);
|
|
2092
|
+
return `${header}
|
|
2093
|
+
${diff}`;
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
registry.register({
|
|
2097
|
+
name: "create_directory",
|
|
2098
|
+
description: "Create a directory (and any missing parents) under the sandbox root.",
|
|
2099
|
+
parameters: {
|
|
2100
|
+
type: "object",
|
|
2101
|
+
properties: { path: { type: "string" } },
|
|
2102
|
+
required: ["path"]
|
|
2103
|
+
},
|
|
2104
|
+
fn: async (args) => {
|
|
2105
|
+
const abs = safePath(args.path);
|
|
2106
|
+
await fs.mkdir(abs, { recursive: true });
|
|
2107
|
+
return `created ${pathMod.relative(rootDir, abs)}/`;
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
registry.register({
|
|
2111
|
+
name: "move_file",
|
|
2112
|
+
description: "Rename/move a file or directory under the sandbox root.",
|
|
2113
|
+
parameters: {
|
|
2114
|
+
type: "object",
|
|
2115
|
+
properties: {
|
|
2116
|
+
source: { type: "string" },
|
|
2117
|
+
destination: { type: "string" }
|
|
2118
|
+
},
|
|
2119
|
+
required: ["source", "destination"]
|
|
2120
|
+
},
|
|
2121
|
+
fn: async (args) => {
|
|
2122
|
+
const src = safePath(args.source);
|
|
2123
|
+
const dst = safePath(args.destination);
|
|
2124
|
+
await fs.mkdir(pathMod.dirname(dst), { recursive: true });
|
|
2125
|
+
await fs.rename(src, dst);
|
|
2126
|
+
return `moved ${pathMod.relative(rootDir, src)} \u2192 ${pathMod.relative(rootDir, dst)}`;
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
return registry;
|
|
2130
|
+
}
|
|
2131
|
+
function renderEditDiff(search, replace, startLine) {
|
|
2132
|
+
const a = search.split(/\r?\n/);
|
|
2133
|
+
const b = replace.split(/\r?\n/);
|
|
2134
|
+
const diff = lineDiff(a, b);
|
|
2135
|
+
const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
|
|
2136
|
+
const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
|
|
2137
|
+
return `${hunk}
|
|
2138
|
+
${body}`;
|
|
2139
|
+
}
|
|
2140
|
+
function lineDiff(a, b) {
|
|
2141
|
+
const n = a.length;
|
|
2142
|
+
const m = b.length;
|
|
2143
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
2144
|
+
for (let i2 = 1; i2 <= n; i2++) {
|
|
2145
|
+
for (let j2 = 1; j2 <= m; j2++) {
|
|
2146
|
+
if (a[i2 - 1] === b[j2 - 1]) dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
|
|
2147
|
+
else dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
const out = [];
|
|
2151
|
+
let i = n;
|
|
2152
|
+
let j = m;
|
|
2153
|
+
while (i > 0 && j > 0) {
|
|
2154
|
+
if (a[i - 1] === b[j - 1]) {
|
|
2155
|
+
out.unshift({ op: " ", line: a[i - 1] });
|
|
2156
|
+
i--;
|
|
2157
|
+
j--;
|
|
2158
|
+
} else if ((dp[i - 1][j] ?? 0) > (dp[i][j - 1] ?? 0)) {
|
|
2159
|
+
out.unshift({ op: "-", line: a[i - 1] });
|
|
2160
|
+
i--;
|
|
2161
|
+
} else {
|
|
2162
|
+
out.unshift({ op: "+", line: b[j - 1] });
|
|
2163
|
+
j--;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
while (i > 0) {
|
|
2167
|
+
out.unshift({ op: "-", line: a[i - 1] });
|
|
2168
|
+
i--;
|
|
2169
|
+
}
|
|
2170
|
+
while (j > 0) {
|
|
2171
|
+
out.unshift({ op: "+", line: b[j - 1] });
|
|
2172
|
+
j--;
|
|
2173
|
+
}
|
|
2174
|
+
return out;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
1666
2177
|
// src/env.ts
|
|
1667
2178
|
import { readFileSync as readFileSync2 } from "fs";
|
|
1668
|
-
import { resolve } from "path";
|
|
2179
|
+
import { resolve as resolve2 } from "path";
|
|
1669
2180
|
function loadDotenv(path = ".env") {
|
|
1670
2181
|
let raw;
|
|
1671
2182
|
try {
|
|
1672
|
-
raw = readFileSync2(
|
|
2183
|
+
raw = readFileSync2(resolve2(process.cwd(), path), "utf8");
|
|
1673
2184
|
} catch {
|
|
1674
2185
|
return;
|
|
1675
2186
|
}
|
|
@@ -1829,6 +2340,8 @@ function computeReplayStats(records) {
|
|
|
1829
2340
|
}
|
|
1830
2341
|
function summarizeTurns(turns) {
|
|
1831
2342
|
const totalCost = turns.reduce((s, t) => s + t.cost, 0);
|
|
2343
|
+
const totalInput = turns.reduce((s, t) => s + inputCostUsd(t.model, t.usage), 0);
|
|
2344
|
+
const totalOutput = turns.reduce((s, t) => s + outputCostUsd(t.model, t.usage), 0);
|
|
1832
2345
|
const totalClaude = turns.reduce((s, t) => s + claudeEquivalentCost(t.usage), 0);
|
|
1833
2346
|
let hit = 0;
|
|
1834
2347
|
let miss = 0;
|
|
@@ -1842,6 +2355,8 @@ function summarizeTurns(turns) {
|
|
|
1842
2355
|
return {
|
|
1843
2356
|
turns: turns.length,
|
|
1844
2357
|
totalCostUsd: round2(totalCost, 6),
|
|
2358
|
+
totalInputCostUsd: round2(totalInput, 6),
|
|
2359
|
+
totalOutputCostUsd: round2(totalOutput, 6),
|
|
1845
2360
|
claudeEquivalentUsd: round2(totalClaude, 6),
|
|
1846
2361
|
savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
|
|
1847
2362
|
cacheHitRatio: round2(cacheHitRatio, 4),
|
|
@@ -2199,6 +2714,13 @@ var McpClient = class {
|
|
|
2199
2714
|
_serverInfo = { name: "", version: "" };
|
|
2200
2715
|
_protocolVersion = "";
|
|
2201
2716
|
_instructions;
|
|
2717
|
+
// Progress-token → handler for notifications/progress routing. Tokens
|
|
2718
|
+
// are minted per call when the caller supplies an onProgress
|
|
2719
|
+
// callback; cleared when the final response lands (or the pending
|
|
2720
|
+
// request rejects). No leaks — the `try/finally` in callTool
|
|
2721
|
+
// guarantees cleanup even on timeout.
|
|
2722
|
+
progressHandlers = /* @__PURE__ */ new Map();
|
|
2723
|
+
nextProgressToken = 1;
|
|
2202
2724
|
constructor(opts) {
|
|
2203
2725
|
this.transport = opts.transport;
|
|
2204
2726
|
this.clientInfo = opts.clientInfo ?? { name: "reasonix", version: "0.3.0-dev" };
|
|
@@ -2253,13 +2775,36 @@ var McpClient = class {
|
|
|
2253
2775
|
this.assertInitialized();
|
|
2254
2776
|
return this.request("tools/list", {});
|
|
2255
2777
|
}
|
|
2256
|
-
/**
|
|
2257
|
-
|
|
2778
|
+
/**
|
|
2779
|
+
* Invoke a tool by name. When `onProgress` is supplied, attaches a
|
|
2780
|
+
* fresh progress token so the server can send incremental updates
|
|
2781
|
+
* via `notifications/progress`; they're routed to the callback until
|
|
2782
|
+
* the final response arrives (or the request times out, in which
|
|
2783
|
+
* case the handler is simply dropped — no extra notification).
|
|
2784
|
+
*
|
|
2785
|
+
* When `signal` is supplied, aborting it:
|
|
2786
|
+
* 1) fires `notifications/cancelled` to the server (MCP 2024-11-05
|
|
2787
|
+
* way of saying "forget this request, I no longer care"), and
|
|
2788
|
+
* 2) rejects the pending promise immediately with an AbortError,
|
|
2789
|
+
* so the caller doesn't have to wait for the subprocess to
|
|
2790
|
+
* finish its in-flight file write or network request.
|
|
2791
|
+
* The server MAY still emit a late response; we drop it in dispatch
|
|
2792
|
+
* since the request id is gone from `pending`.
|
|
2793
|
+
*/
|
|
2794
|
+
async callTool(name, args, opts = {}) {
|
|
2258
2795
|
this.assertInitialized();
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2796
|
+
const params = { name, arguments: args ?? {} };
|
|
2797
|
+
let token;
|
|
2798
|
+
if (opts.onProgress) {
|
|
2799
|
+
token = this.nextProgressToken++;
|
|
2800
|
+
this.progressHandlers.set(token, opts.onProgress);
|
|
2801
|
+
params._meta = { progressToken: token };
|
|
2802
|
+
}
|
|
2803
|
+
try {
|
|
2804
|
+
return await this.request("tools/call", params, opts.signal);
|
|
2805
|
+
} finally {
|
|
2806
|
+
if (token !== void 0) this.progressHandlers.delete(token);
|
|
2807
|
+
}
|
|
2263
2808
|
}
|
|
2264
2809
|
/**
|
|
2265
2810
|
* List resources the server exposes. Supports a pagination cursor;
|
|
@@ -2313,24 +2858,56 @@ var McpClient = class {
|
|
|
2313
2858
|
assertInitialized() {
|
|
2314
2859
|
if (!this.initialized) throw new Error("MCP client not initialized \u2014 call initialize() first");
|
|
2315
2860
|
}
|
|
2316
|
-
async request(method, params) {
|
|
2861
|
+
async request(method, params, signal) {
|
|
2317
2862
|
const id = this.nextId++;
|
|
2318
2863
|
const frame = { jsonrpc: "2.0", id, method, params };
|
|
2319
|
-
|
|
2864
|
+
let abortHandler = null;
|
|
2865
|
+
const promise = new Promise((resolve4, reject) => {
|
|
2320
2866
|
const timeout = setTimeout(() => {
|
|
2321
2867
|
this.pending.delete(id);
|
|
2868
|
+
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
|
|
2322
2869
|
reject(
|
|
2323
2870
|
new Error(`MCP request ${method} (id=${id}) timed out after ${this.requestTimeoutMs}ms`)
|
|
2324
2871
|
);
|
|
2325
2872
|
}, this.requestTimeoutMs);
|
|
2326
2873
|
this.pending.set(id, {
|
|
2327
|
-
resolve:
|
|
2874
|
+
resolve: resolve4,
|
|
2328
2875
|
reject,
|
|
2329
2876
|
timeout
|
|
2330
2877
|
});
|
|
2878
|
+
if (signal) {
|
|
2879
|
+
if (signal.aborted) {
|
|
2880
|
+
this.pending.delete(id);
|
|
2881
|
+
clearTimeout(timeout);
|
|
2882
|
+
reject(new Error(`MCP request ${method} (id=${id}) aborted before send`));
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
abortHandler = () => {
|
|
2886
|
+
this.pending.delete(id);
|
|
2887
|
+
clearTimeout(timeout);
|
|
2888
|
+
void this.transport.send({
|
|
2889
|
+
jsonrpc: "2.0",
|
|
2890
|
+
method: "notifications/cancelled",
|
|
2891
|
+
params: { requestId: id, reason: "aborted by user" }
|
|
2892
|
+
}).catch(() => {
|
|
2893
|
+
});
|
|
2894
|
+
reject(new Error(`MCP request ${method} (id=${id}) aborted by user`));
|
|
2895
|
+
};
|
|
2896
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
2897
|
+
}
|
|
2331
2898
|
});
|
|
2332
|
-
|
|
2333
|
-
|
|
2899
|
+
try {
|
|
2900
|
+
await this.transport.send(frame);
|
|
2901
|
+
} catch (err) {
|
|
2902
|
+
this.pending.delete(id);
|
|
2903
|
+
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
|
|
2904
|
+
throw err;
|
|
2905
|
+
}
|
|
2906
|
+
try {
|
|
2907
|
+
return await promise;
|
|
2908
|
+
} finally {
|
|
2909
|
+
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
|
|
2910
|
+
}
|
|
2334
2911
|
}
|
|
2335
2912
|
startReaderIfNeeded() {
|
|
2336
2913
|
if (this.readerStarted) return;
|
|
@@ -2351,7 +2928,16 @@ var McpClient = class {
|
|
|
2351
2928
|
}
|
|
2352
2929
|
}
|
|
2353
2930
|
dispatch(msg) {
|
|
2354
|
-
if (!("id" in msg) || msg.id === null || msg.id === void 0)
|
|
2931
|
+
if (!("id" in msg) || msg.id === null || msg.id === void 0) {
|
|
2932
|
+
if ("method" in msg && msg.method === "notifications/progress") {
|
|
2933
|
+
const p = msg.params;
|
|
2934
|
+
if (!p || p.progressToken === void 0) return;
|
|
2935
|
+
const handler = this.progressHandlers.get(p.progressToken);
|
|
2936
|
+
if (!handler) return;
|
|
2937
|
+
handler({ progress: p.progress, total: p.total, message: p.message });
|
|
2938
|
+
}
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2355
2941
|
if (!("result" in msg) && !("error" in msg)) return;
|
|
2356
2942
|
const pending = this.pending.get(msg.id);
|
|
2357
2943
|
if (!pending) return;
|
|
@@ -2408,12 +2994,12 @@ var StdioTransport = class {
|
|
|
2408
2994
|
}
|
|
2409
2995
|
async send(message) {
|
|
2410
2996
|
if (this.closed) throw new Error("MCP transport is closed");
|
|
2411
|
-
return new Promise((
|
|
2997
|
+
return new Promise((resolve4, reject) => {
|
|
2412
2998
|
const line = `${JSON.stringify(message)}
|
|
2413
2999
|
`;
|
|
2414
3000
|
this.child.stdin.write(line, "utf8", (err) => {
|
|
2415
3001
|
if (err) reject(err);
|
|
2416
|
-
else
|
|
3002
|
+
else resolve4();
|
|
2417
3003
|
});
|
|
2418
3004
|
});
|
|
2419
3005
|
}
|
|
@@ -2424,8 +3010,8 @@ var StdioTransport = class {
|
|
|
2424
3010
|
continue;
|
|
2425
3011
|
}
|
|
2426
3012
|
if (this.closed) return;
|
|
2427
|
-
const next = await new Promise((
|
|
2428
|
-
this.waiters.push(
|
|
3013
|
+
const next = await new Promise((resolve4) => {
|
|
3014
|
+
this.waiters.push(resolve4);
|
|
2429
3015
|
});
|
|
2430
3016
|
if (next === null) return;
|
|
2431
3017
|
yield next;
|
|
@@ -2491,8 +3077,8 @@ var SseTransport = class {
|
|
|
2491
3077
|
constructor(opts) {
|
|
2492
3078
|
this.url = opts.url;
|
|
2493
3079
|
this.headers = opts.headers ?? {};
|
|
2494
|
-
this.endpointReady = new Promise((
|
|
2495
|
-
this.resolveEndpoint =
|
|
3080
|
+
this.endpointReady = new Promise((resolve4, reject) => {
|
|
3081
|
+
this.resolveEndpoint = resolve4;
|
|
2496
3082
|
this.rejectEndpoint = reject;
|
|
2497
3083
|
});
|
|
2498
3084
|
this.endpointReady.catch(() => void 0);
|
|
@@ -2519,8 +3105,8 @@ var SseTransport = class {
|
|
|
2519
3105
|
continue;
|
|
2520
3106
|
}
|
|
2521
3107
|
if (this.closed) return;
|
|
2522
|
-
const next = await new Promise((
|
|
2523
|
-
this.waiters.push(
|
|
3108
|
+
const next = await new Promise((resolve4) => {
|
|
3109
|
+
this.waiters.push(resolve4);
|
|
2524
3110
|
});
|
|
2525
3111
|
if (next === null) return;
|
|
2526
3112
|
yield next;
|
|
@@ -2720,7 +3306,7 @@ async function trySection(load) {
|
|
|
2720
3306
|
|
|
2721
3307
|
// src/code/edit-blocks.ts
|
|
2722
3308
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2723
|
-
import { dirname as
|
|
3309
|
+
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
2724
3310
|
var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
|
|
2725
3311
|
function parseEditBlocks(text) {
|
|
2726
3312
|
const out = [];
|
|
@@ -2738,8 +3324,8 @@ function parseEditBlocks(text) {
|
|
|
2738
3324
|
return out;
|
|
2739
3325
|
}
|
|
2740
3326
|
function applyEditBlock(block, rootDir) {
|
|
2741
|
-
const absRoot =
|
|
2742
|
-
const absTarget =
|
|
3327
|
+
const absRoot = resolve3(rootDir);
|
|
3328
|
+
const absTarget = resolve3(absRoot, block.path);
|
|
2743
3329
|
if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
|
|
2744
3330
|
return {
|
|
2745
3331
|
path: block.path,
|
|
@@ -2758,7 +3344,7 @@ function applyEditBlock(block, rootDir) {
|
|
|
2758
3344
|
message: "file does not exist; to create it, use an empty SEARCH block"
|
|
2759
3345
|
};
|
|
2760
3346
|
}
|
|
2761
|
-
mkdirSync2(
|
|
3347
|
+
mkdirSync2(dirname3(absTarget), { recursive: true });
|
|
2762
3348
|
writeFileSync2(absTarget, block.replace, "utf8");
|
|
2763
3349
|
return { path: block.path, status: "created" };
|
|
2764
3350
|
}
|
|
@@ -2789,13 +3375,13 @@ function applyEditBlocks(blocks, rootDir) {
|
|
|
2789
3375
|
return blocks.map((b) => applyEditBlock(b, rootDir));
|
|
2790
3376
|
}
|
|
2791
3377
|
function snapshotBeforeEdits(blocks, rootDir) {
|
|
2792
|
-
const absRoot =
|
|
3378
|
+
const absRoot = resolve3(rootDir);
|
|
2793
3379
|
const seen = /* @__PURE__ */ new Set();
|
|
2794
3380
|
const snapshots = [];
|
|
2795
3381
|
for (const b of blocks) {
|
|
2796
3382
|
if (seen.has(b.path)) continue;
|
|
2797
3383
|
seen.add(b.path);
|
|
2798
|
-
const abs =
|
|
3384
|
+
const abs = resolve3(absRoot, b.path);
|
|
2799
3385
|
if (!existsSync2(abs)) {
|
|
2800
3386
|
snapshots.push({ path: b.path, prevContent: null });
|
|
2801
3387
|
continue;
|
|
@@ -2809,9 +3395,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
|
|
|
2809
3395
|
return snapshots;
|
|
2810
3396
|
}
|
|
2811
3397
|
function restoreSnapshots(snapshots, rootDir) {
|
|
2812
|
-
const absRoot =
|
|
3398
|
+
const absRoot = resolve3(rootDir);
|
|
2813
3399
|
return snapshots.map((snap) => {
|
|
2814
|
-
const abs =
|
|
3400
|
+
const abs = resolve3(absRoot, snap.path);
|
|
2815
3401
|
if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
|
|
2816
3402
|
return {
|
|
2817
3403
|
path: snap.path,
|
|
@@ -2845,7 +3431,7 @@ function sep() {
|
|
|
2845
3431
|
|
|
2846
3432
|
// src/code/prompt.ts
|
|
2847
3433
|
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
2848
|
-
import { join as
|
|
3434
|
+
import { join as join3 } from "path";
|
|
2849
3435
|
var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
|
|
2850
3436
|
|
|
2851
3437
|
# When to edit vs. when to explore
|
|
@@ -2894,7 +3480,7 @@ Rules:
|
|
|
2894
3480
|
- If you need to explore first (list / grep / read), do it with tool calls before writing any prose \u2014 silence while exploring is fine.
|
|
2895
3481
|
`;
|
|
2896
3482
|
function codeSystemPrompt(rootDir) {
|
|
2897
|
-
const gitignorePath =
|
|
3483
|
+
const gitignorePath = join3(rootDir, ".gitignore");
|
|
2898
3484
|
if (!existsSync3(gitignorePath)) return CODE_SYSTEM_PROMPT;
|
|
2899
3485
|
let content;
|
|
2900
3486
|
try {
|
|
@@ -2920,9 +3506,9 @@ ${truncated}
|
|
|
2920
3506
|
// src/config.ts
|
|
2921
3507
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
|
|
2922
3508
|
import { homedir as homedir2 } from "os";
|
|
2923
|
-
import { dirname as
|
|
3509
|
+
import { dirname as dirname4, join as join4 } from "path";
|
|
2924
3510
|
function defaultConfigPath() {
|
|
2925
|
-
return
|
|
3511
|
+
return join4(homedir2(), ".reasonix", "config.json");
|
|
2926
3512
|
}
|
|
2927
3513
|
function readConfig(path = defaultConfigPath()) {
|
|
2928
3514
|
try {
|
|
@@ -2934,7 +3520,7 @@ function readConfig(path = defaultConfigPath()) {
|
|
|
2934
3520
|
return {};
|
|
2935
3521
|
}
|
|
2936
3522
|
function writeConfig(cfg, path = defaultConfigPath()) {
|
|
2937
|
-
mkdirSync3(
|
|
3523
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
2938
3524
|
writeFileSync3(path, JSON.stringify(cfg, null, 2), "utf8");
|
|
2939
3525
|
try {
|
|
2940
3526
|
chmodSync2(path, 384);
|
|
@@ -3001,6 +3587,7 @@ export {
|
|
|
3001
3587
|
formatLoopError,
|
|
3002
3588
|
harvest,
|
|
3003
3589
|
healLoadedMessages,
|
|
3590
|
+
inputCostUsd,
|
|
3004
3591
|
inspectMcpServer,
|
|
3005
3592
|
isJsonRpcError,
|
|
3006
3593
|
isPlanStateEmpty,
|
|
@@ -3011,6 +3598,7 @@ export {
|
|
|
3011
3598
|
loadSessionMessages,
|
|
3012
3599
|
nestArguments,
|
|
3013
3600
|
openTranscriptFile,
|
|
3601
|
+
outputCostUsd,
|
|
3014
3602
|
parseEditBlocks,
|
|
3015
3603
|
parseMcpSpec,
|
|
3016
3604
|
parseTranscript,
|
|
@@ -3018,6 +3606,7 @@ export {
|
|
|
3018
3606
|
readTranscript,
|
|
3019
3607
|
recordFromLoopEvent,
|
|
3020
3608
|
redactKey,
|
|
3609
|
+
registerFilesystemTools,
|
|
3021
3610
|
renderMarkdown as renderDiffMarkdown,
|
|
3022
3611
|
renderSummaryTable as renderDiffSummary,
|
|
3023
3612
|
repairTruncatedJson,
|