reasonix 0.25.1 → 0.26.1

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/index.js CHANGED
@@ -451,6 +451,54 @@ function resolveTemperatures(budget, custom) {
451
451
  return out;
452
452
  }
453
453
 
454
+ // src/core/pause-gate.ts
455
+ var PauseGate = class {
456
+ _nextId = 0;
457
+ _pending = /* @__PURE__ */ new Map();
458
+ _listeners = /* @__PURE__ */ new Set();
459
+ /** Block until the user responds. Takes a named options object so the
460
+ * kind and payload fields don't get confused at the call site. */
461
+ ask(opts) {
462
+ const { kind, payload } = opts;
463
+ if (this._listeners.size === 0) {
464
+ throw new Error(
465
+ `${kind}: no confirmation listener registered \u2014 cannot prompt the user. This tool can only be used inside an interactive Reasonix session.`
466
+ );
467
+ }
468
+ return new Promise((resolve10) => {
469
+ const id = this._nextId++;
470
+ const request = { id, kind, payload };
471
+ this._pending.set(id, { resolve: resolve10, request });
472
+ for (const fn of this._listeners) {
473
+ try {
474
+ fn(request);
475
+ } catch {
476
+ }
477
+ }
478
+ });
479
+ }
480
+ /** Resolve a pending request. Called by the App's modal callback. */
481
+ resolve(id, data) {
482
+ const p = this._pending.get(id);
483
+ if (!p) return;
484
+ this._pending.delete(id);
485
+ p.resolve(data);
486
+ }
487
+ /** Subscribe to new pause requests. Returns an unsubscribe function. */
488
+ on(fn) {
489
+ this._listeners.add(fn);
490
+ return () => {
491
+ this._listeners.delete(fn);
492
+ };
493
+ }
494
+ /** Current pending request, if any (polling fallback). */
495
+ get current() {
496
+ for (const [, p] of this._pending) return p.request;
497
+ return null;
498
+ }
499
+ };
500
+ var pauseGate = new PauseGate();
501
+
454
502
  // src/hooks.ts
455
503
  import { spawn } from "child_process";
456
504
  import { existsSync, readFileSync } from "fs";
@@ -989,7 +1037,10 @@ var ToolRegistry = class {
989
1037
  }
990
1038
  }
991
1039
  try {
992
- const result = await tool.fn(args, { signal: opts.signal });
1040
+ const result = await tool.fn(args, {
1041
+ signal: opts.signal,
1042
+ confirmationGate: opts.confirmationGate
1043
+ });
993
1044
  const str = typeof result === "string" ? result : JSON.stringify(result);
994
1045
  let clipped = str;
995
1046
  if (opts.maxResultTokens !== void 0) {
@@ -1193,98 +1244,6 @@ function blockToString(block) {
1193
1244
  return `[unknown block: ${JSON.stringify(block)}]`;
1194
1245
  }
1195
1246
 
1196
- // src/memory/runtime.ts
1197
- import { createHash } from "crypto";
1198
- var ImmutablePrefix = class {
1199
- system;
1200
- /** Each `addTool` costs one cache-miss turn — DeepSeek's prefix cache is keyed by full tool list. */
1201
- _toolSpecs;
1202
- fewShots;
1203
- /** Invalidated only via `addTool`; bypassing it leaves cache stale → fingerprint diverges from sent prefix. */
1204
- _fingerprintCache = null;
1205
- constructor(opts) {
1206
- this.system = opts.system;
1207
- this._toolSpecs = [...opts.toolSpecs ?? []];
1208
- this.fewShots = Object.freeze([...opts.fewShots ?? []]);
1209
- }
1210
- get toolSpecs() {
1211
- return this._toolSpecs;
1212
- }
1213
- toMessages() {
1214
- return [{ role: "system", content: this.system }, ...this.fewShots.map((m) => ({ ...m }))];
1215
- }
1216
- tools() {
1217
- return this._toolSpecs.map((t) => structuredClone(t));
1218
- }
1219
- addTool(spec) {
1220
- const name = spec.function?.name;
1221
- if (!name) return false;
1222
- if (this._toolSpecs.some((t) => t.function?.name === name)) return false;
1223
- this._toolSpecs.push(spec);
1224
- this._fingerprintCache = null;
1225
- return true;
1226
- }
1227
- get fingerprint() {
1228
- if (this._fingerprintCache !== null) return this._fingerprintCache;
1229
- this._fingerprintCache = this.computeFingerprint();
1230
- return this._fingerprintCache;
1231
- }
1232
- /** Dev/test only — throws on cache drift, which always means a non-`addTool` mutation slipped in. */
1233
- verifyFingerprint() {
1234
- const fresh = this.computeFingerprint();
1235
- if (this._fingerprintCache !== null && this._fingerprintCache !== fresh) {
1236
- throw new Error(
1237
- `ImmutablePrefix fingerprint drift: cached=${this._fingerprintCache}, fresh=${fresh}. A mutation path bypassed addTool's cache invalidation \u2014 DeepSeek will see prefix churn that the TUI / transcript log don't know about.`
1238
- );
1239
- }
1240
- this._fingerprintCache = fresh;
1241
- return fresh;
1242
- }
1243
- computeFingerprint() {
1244
- const blob = JSON.stringify({
1245
- system: this.system,
1246
- tools: this._toolSpecs,
1247
- shots: this.fewShots
1248
- });
1249
- return createHash("sha256").update(blob).digest("hex").slice(0, 16);
1250
- }
1251
- };
1252
- var AppendOnlyLog = class {
1253
- _entries = [];
1254
- append(message) {
1255
- if (!message || typeof message !== "object" || !("role" in message)) {
1256
- throw new Error(`invalid log entry: ${JSON.stringify(message)}`);
1257
- }
1258
- this._entries.push(message);
1259
- }
1260
- extend(messages) {
1261
- for (const m of messages) this.append(m);
1262
- }
1263
- /** The one append-only-breaking path — reserved for `/compact` + recovery. Use `append()` otherwise. */
1264
- compactInPlace(replacement) {
1265
- this._entries = [...replacement];
1266
- }
1267
- get entries() {
1268
- return this._entries;
1269
- }
1270
- toMessages() {
1271
- return this._entries.map((e) => ({ ...e }));
1272
- }
1273
- get length() {
1274
- return this._entries.length;
1275
- }
1276
- };
1277
- var VolatileScratch = class {
1278
- reasoning = null;
1279
- planState = null;
1280
- notes = [];
1281
- reset() {
1282
- this.reasoning = null;
1283
- this.planState = null;
1284
- this.notes = [];
1285
- }
1286
- };
1287
-
1288
1247
  // src/memory/session.ts
1289
1248
  import { execFileSync } from "child_process";
1290
1249
  import {
@@ -1415,6 +1374,338 @@ function countLines(path2) {
1415
1374
  }
1416
1375
  }
1417
1376
 
1377
+ // src/telemetry/stats.ts
1378
+ var DEEPSEEK_PRICING = {
1379
+ "deepseek-v4-flash": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
1380
+ "deepseek-v4-pro": { inputCacheHit: 0.139, inputCacheMiss: 1.667, output: 3.333 },
1381
+ // Compat aliases — priced as v4-flash per the deprecation notice.
1382
+ "deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
1383
+ "deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 }
1384
+ };
1385
+ var CLAUDE_SONNET_PRICING = { input: 3, output: 15 };
1386
+ var DEEPSEEK_CONTEXT_TOKENS = {
1387
+ "deepseek-v4-flash": 1e6,
1388
+ "deepseek-v4-pro": 1e6,
1389
+ "deepseek-chat": 1e6,
1390
+ "deepseek-reasoner": 1e6
1391
+ };
1392
+ var DEFAULT_CONTEXT_TOKENS = 131072;
1393
+ function costUsd(model, usage) {
1394
+ const p = DEEPSEEK_PRICING[model];
1395
+ if (!p) return 0;
1396
+ return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss + usage.completionTokens * p.output) / 1e6;
1397
+ }
1398
+ function inputCostUsd(model, usage) {
1399
+ const p = DEEPSEEK_PRICING[model];
1400
+ if (!p) return 0;
1401
+ return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss) / 1e6;
1402
+ }
1403
+ function outputCostUsd(model, usage) {
1404
+ const p = DEEPSEEK_PRICING[model];
1405
+ if (!p) return 0;
1406
+ return usage.completionTokens * p.output / 1e6;
1407
+ }
1408
+ function cacheSavingsUsd(model, hitTokens) {
1409
+ if (hitTokens <= 0) return 0;
1410
+ const p = DEEPSEEK_PRICING[model];
1411
+ if (!p) return 0;
1412
+ return hitTokens * (p.inputCacheMiss - p.inputCacheHit) / 1e6;
1413
+ }
1414
+ function claudeEquivalentCost(usage) {
1415
+ return (usage.promptTokens * CLAUDE_SONNET_PRICING.input + usage.completionTokens * CLAUDE_SONNET_PRICING.output) / 1e6;
1416
+ }
1417
+ var SessionStats = class {
1418
+ turns = [];
1419
+ record(turn, model, usage) {
1420
+ const cost = costUsd(model, usage);
1421
+ const stats = {
1422
+ turn,
1423
+ model,
1424
+ usage,
1425
+ cost,
1426
+ cacheHitRatio: usage.cacheHitRatio
1427
+ };
1428
+ this.turns.push(stats);
1429
+ return stats;
1430
+ }
1431
+ get totalCost() {
1432
+ return this.turns.reduce((sum, t) => sum + t.cost, 0);
1433
+ }
1434
+ get totalClaudeEquivalent() {
1435
+ return this.turns.reduce((sum, t) => sum + claudeEquivalentCost(t.usage), 0);
1436
+ }
1437
+ get savingsVsClaude() {
1438
+ const c = this.totalClaudeEquivalent;
1439
+ return c > 0 ? 1 - this.totalCost / c : 0;
1440
+ }
1441
+ get totalInputCost() {
1442
+ return this.turns.reduce((sum, t) => sum + inputCostUsd(t.model, t.usage), 0);
1443
+ }
1444
+ get totalOutputCost() {
1445
+ return this.turns.reduce((sum, t) => sum + outputCostUsd(t.model, t.usage), 0);
1446
+ }
1447
+ get aggregateCacheHitRatio() {
1448
+ let hit = 0;
1449
+ let miss = 0;
1450
+ for (const t of this.turns) {
1451
+ hit += t.usage.promptCacheHitTokens;
1452
+ miss += t.usage.promptCacheMissTokens;
1453
+ }
1454
+ const denom = hit + miss;
1455
+ return denom > 0 ? hit / denom : 0;
1456
+ }
1457
+ summary() {
1458
+ const last = this.turns[this.turns.length - 1];
1459
+ return {
1460
+ turns: this.turns.length,
1461
+ totalCostUsd: round(this.totalCost, 6),
1462
+ totalInputCostUsd: round(this.totalInputCost, 6),
1463
+ totalOutputCostUsd: round(this.totalOutputCost, 6),
1464
+ claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1465
+ savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1466
+ cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1467
+ lastPromptTokens: last?.usage.promptTokens ?? 0,
1468
+ lastTurnCostUsd: round(last?.cost ?? 0, 6)
1469
+ };
1470
+ }
1471
+ };
1472
+ function round(n, digits) {
1473
+ const f = 10 ** digits;
1474
+ return Math.round(n * f) / f;
1475
+ }
1476
+
1477
+ // src/context-manager.ts
1478
+ var HISTORY_FOLD_THRESHOLD = 0.5;
1479
+ var HISTORY_FOLD_TAIL_FRACTION = 0.2;
1480
+ var HISTORY_FOLD_AGGRESSIVE_THRESHOLD = 0.7;
1481
+ var HISTORY_FOLD_AGGRESSIVE_TAIL_FRACTION = 0.1;
1482
+ var HISTORY_FOLD_MIN_SAVINGS_FRACTION = 0.3;
1483
+ var FORCE_SUMMARY_THRESHOLD = 0.8;
1484
+ var PREFLIGHT_EMERGENCY_THRESHOLD = 0.95;
1485
+ var HISTORY_FOLD_MARKER = "[CONVERSATION HISTORY SUMMARY \u2014 earlier turns folded for context efficiency]\n\n";
1486
+ var ContextManager = class {
1487
+ constructor(deps) {
1488
+ this.deps = deps;
1489
+ }
1490
+ deps;
1491
+ /** Decision after a turn's response — fold, exit with summary, or carry on. */
1492
+ decideAfterUsage(usage, model, alreadyFoldedThisTurn) {
1493
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
1494
+ if (!usage) return { kind: "none", promptTokens: 0, ctxMax, ratio: 0 };
1495
+ const ratio = usage.promptTokens / ctxMax;
1496
+ const base = { promptTokens: usage.promptTokens, ctxMax, ratio };
1497
+ if (ratio > FORCE_SUMMARY_THRESHOLD) {
1498
+ return { kind: "exit-with-summary", ...base };
1499
+ }
1500
+ if (alreadyFoldedThisTurn) return { kind: "none", ...base };
1501
+ if (ratio > HISTORY_FOLD_AGGRESSIVE_THRESHOLD) {
1502
+ return {
1503
+ kind: "fold",
1504
+ ...base,
1505
+ tailBudget: Math.floor(ctxMax * HISTORY_FOLD_AGGRESSIVE_TAIL_FRACTION),
1506
+ aggressive: true
1507
+ };
1508
+ }
1509
+ if (ratio > HISTORY_FOLD_THRESHOLD) {
1510
+ return {
1511
+ kind: "fold",
1512
+ ...base,
1513
+ tailBudget: Math.floor(ctxMax * HISTORY_FOLD_TAIL_FRACTION),
1514
+ aggressive: false
1515
+ };
1516
+ }
1517
+ return { kind: "none", ...base };
1518
+ }
1519
+ /** Local-side preflight before sending a request — catches oversized payloads early. */
1520
+ decidePreflight(messages, toolSpecs, model) {
1521
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
1522
+ const estimate = estimateRequestTokens(messages, toolSpecs ?? null);
1523
+ return {
1524
+ needsAction: estimate / ctxMax > PREFLIGHT_EMERGENCY_THRESHOLD,
1525
+ estimateTokens: estimate,
1526
+ ctxMax
1527
+ };
1528
+ }
1529
+ /** Replace older turns with one summary message; keep tail within keepRecentTokens budget. */
1530
+ async fold(model, opts) {
1531
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
1532
+ const tailBudget = opts?.keepRecentTokens ?? Math.floor(ctxMax * HISTORY_FOLD_TAIL_FRACTION);
1533
+ const all = this.deps.log.toMessages();
1534
+ const noop = {
1535
+ folded: false,
1536
+ beforeMessages: all.length,
1537
+ afterMessages: all.length,
1538
+ summaryChars: 0
1539
+ };
1540
+ if (all.length === 0) return noop;
1541
+ const tokenCounts = all.map((m) => estimateConversationTokens([m]));
1542
+ const totalTokens = tokenCounts.reduce((a, b) => a + b, 0);
1543
+ let cumTokens = 0;
1544
+ let boundary = all.length;
1545
+ for (let i = all.length - 1; i >= 0; i--) {
1546
+ if (cumTokens + tokenCounts[i] > tailBudget) break;
1547
+ cumTokens += tokenCounts[i];
1548
+ if (all[i].role === "user") boundary = i;
1549
+ }
1550
+ if (boundary <= 0) return noop;
1551
+ const head = all.slice(0, boundary);
1552
+ const tail = all.slice(boundary);
1553
+ const headTokens = totalTokens - cumTokens;
1554
+ if (headTokens < totalTokens * HISTORY_FOLD_MIN_SAVINGS_FRACTION) return noop;
1555
+ const summary = await this.summarizeForFold(head);
1556
+ if (!summary) return noop;
1557
+ const summaryMsg = {
1558
+ role: "assistant",
1559
+ content: HISTORY_FOLD_MARKER + summary
1560
+ };
1561
+ const replacement = [summaryMsg, ...tail];
1562
+ this.deps.log.compactInPlace(replacement);
1563
+ this.persistRewrite(replacement);
1564
+ return {
1565
+ folded: true,
1566
+ beforeMessages: all.length,
1567
+ afterMessages: replacement.length,
1568
+ summaryChars: summary.length
1569
+ };
1570
+ }
1571
+ /** Drop a trailing in-flight assistant-with-tool_calls before a forced summary. Tail-only mutation; prefix cache safe. */
1572
+ trimTrailingToolCalls() {
1573
+ const tail = this.deps.log.entries[this.deps.log.entries.length - 1];
1574
+ if (!tail || tail.role !== "assistant" || !Array.isArray(tail.tool_calls) || tail.tool_calls.length === 0) {
1575
+ return false;
1576
+ }
1577
+ const kept = this.deps.log.entries.slice(0, -1);
1578
+ this.deps.log.compactInPlace([...kept]);
1579
+ this.persistRewrite([...kept]);
1580
+ return true;
1581
+ }
1582
+ async summarizeForFold(messagesToSummarize) {
1583
+ const summaryModel = "deepseek-v4-flash";
1584
+ const systemPrompt = "You compress conversation history for a coding agent. Output one prose recap that preserves: the user's overall goal, decisions and conclusions reached, files inspected or modified, important tool results still relevant to ongoing work, and any open todos. Skip turn-by-turn play-by-play. No tool calls, no markdown headings, no SEARCH/REPLACE blocks \u2014 plain prose only.";
1585
+ const healed = healLoadedMessages(messagesToSummarize, DEFAULT_MAX_RESULT_CHARS).messages;
1586
+ const messages = [
1587
+ { role: "system", content: systemPrompt },
1588
+ ...healed,
1589
+ {
1590
+ role: "user",
1591
+ content: "Summarize the conversation above as plain prose. This summary replaces the original turns to free context \u2014 make it self-contained."
1592
+ }
1593
+ ];
1594
+ try {
1595
+ const resp = await this.deps.client.chat({
1596
+ model: summaryModel,
1597
+ messages,
1598
+ signal: this.deps.getAbortSignal(),
1599
+ thinking: thinkingModeForModel(summaryModel),
1600
+ reasoningEffort: "high"
1601
+ });
1602
+ this.deps.stats.record(this.deps.getCurrentTurn(), summaryModel, resp.usage ?? new Usage());
1603
+ return stripHallucinatedToolMarkup((resp.content ?? "").trim());
1604
+ } catch {
1605
+ return "";
1606
+ }
1607
+ }
1608
+ persistRewrite(messages) {
1609
+ if (!this.deps.sessionName) return;
1610
+ try {
1611
+ rewriteSession(this.deps.sessionName, messages);
1612
+ } catch {
1613
+ }
1614
+ }
1615
+ };
1616
+
1617
+ // src/memory/runtime.ts
1618
+ import { createHash } from "crypto";
1619
+ var ImmutablePrefix = class {
1620
+ system;
1621
+ /** Each `addTool` costs one cache-miss turn — DeepSeek's prefix cache is keyed by full tool list. */
1622
+ _toolSpecs;
1623
+ fewShots;
1624
+ /** Invalidated only via `addTool`; bypassing it leaves cache stale → fingerprint diverges from sent prefix. */
1625
+ _fingerprintCache = null;
1626
+ constructor(opts) {
1627
+ this.system = opts.system;
1628
+ this._toolSpecs = [...opts.toolSpecs ?? []];
1629
+ this.fewShots = Object.freeze([...opts.fewShots ?? []]);
1630
+ }
1631
+ get toolSpecs() {
1632
+ return this._toolSpecs;
1633
+ }
1634
+ toMessages() {
1635
+ return [{ role: "system", content: this.system }, ...this.fewShots.map((m) => ({ ...m }))];
1636
+ }
1637
+ tools() {
1638
+ return this._toolSpecs.map((t) => structuredClone(t));
1639
+ }
1640
+ addTool(spec) {
1641
+ const name = spec.function?.name;
1642
+ if (!name) return false;
1643
+ if (this._toolSpecs.some((t) => t.function?.name === name)) return false;
1644
+ this._toolSpecs.push(spec);
1645
+ this._fingerprintCache = null;
1646
+ return true;
1647
+ }
1648
+ get fingerprint() {
1649
+ if (this._fingerprintCache !== null) return this._fingerprintCache;
1650
+ this._fingerprintCache = this.computeFingerprint();
1651
+ return this._fingerprintCache;
1652
+ }
1653
+ /** Dev/test only — throws on cache drift, which always means a non-`addTool` mutation slipped in. */
1654
+ verifyFingerprint() {
1655
+ const fresh = this.computeFingerprint();
1656
+ if (this._fingerprintCache !== null && this._fingerprintCache !== fresh) {
1657
+ throw new Error(
1658
+ `ImmutablePrefix fingerprint drift: cached=${this._fingerprintCache}, fresh=${fresh}. A mutation path bypassed addTool's cache invalidation \u2014 DeepSeek will see prefix churn that the TUI / transcript log don't know about.`
1659
+ );
1660
+ }
1661
+ this._fingerprintCache = fresh;
1662
+ return fresh;
1663
+ }
1664
+ computeFingerprint() {
1665
+ const blob = JSON.stringify({
1666
+ system: this.system,
1667
+ tools: this._toolSpecs,
1668
+ shots: this.fewShots
1669
+ });
1670
+ return createHash("sha256").update(blob).digest("hex").slice(0, 16);
1671
+ }
1672
+ };
1673
+ var AppendOnlyLog = class {
1674
+ _entries = [];
1675
+ append(message) {
1676
+ if (!message || typeof message !== "object" || !("role" in message)) {
1677
+ throw new Error(`invalid log entry: ${JSON.stringify(message)}`);
1678
+ }
1679
+ this._entries.push(message);
1680
+ }
1681
+ extend(messages) {
1682
+ for (const m of messages) this.append(m);
1683
+ }
1684
+ /** The one append-only-breaking path — reserved for `/compact` + recovery. Use `append()` otherwise. */
1685
+ compactInPlace(replacement) {
1686
+ this._entries = [...replacement];
1687
+ }
1688
+ get entries() {
1689
+ return this._entries;
1690
+ }
1691
+ toMessages() {
1692
+ return this._entries.map((e) => ({ ...e }));
1693
+ }
1694
+ get length() {
1695
+ return this._entries.length;
1696
+ }
1697
+ };
1698
+ var VolatileScratch = class {
1699
+ reasoning = null;
1700
+ planState = null;
1701
+ notes = [];
1702
+ reset() {
1703
+ this.reasoning = null;
1704
+ this.planState = null;
1705
+ this.notes = [];
1706
+ }
1707
+ };
1708
+
1418
1709
  // src/repair/scavenge.ts
1419
1710
  function scavengeToolCalls(reasoningContent, opts) {
1420
1711
  if (!reasoningContent) return { calls: [], notes: [] };
@@ -1655,170 +1946,68 @@ function repairTruncatedJson(input) {
1655
1946
  return { repaired: "{}", changed: true, notes };
1656
1947
  }
1657
1948
  }
1658
-
1659
- // src/repair/index.ts
1660
- var ToolCallRepair = class {
1661
- storm;
1662
- opts;
1663
- constructor(opts) {
1664
- this.opts = opts;
1665
- this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3, opts.isMutating);
1666
- }
1667
- /** Called at start of every user turn — fresh intent shouldn't inherit old repetition state. */
1668
- resetStorm() {
1669
- this.storm.reset();
1670
- }
1671
- process(declaredCalls, reasoningContent, content = null) {
1672
- const report = {
1673
- scavenged: 0,
1674
- truncationsFixed: 0,
1675
- stormsBroken: 0,
1676
- notes: []
1677
- };
1678
- const combined = [reasoningContent ?? "", content ?? ""].filter(Boolean).join("\n");
1679
- const scavenged = scavengeToolCalls(combined || null, {
1680
- allowedNames: this.opts.allowedToolNames,
1681
- maxCalls: this.opts.maxScavenge ?? 4
1682
- });
1683
- const seenSignatures = new Set(declaredCalls.map(signature));
1684
- const merged = [...declaredCalls];
1685
- for (const sc of scavenged.calls) {
1686
- if (!seenSignatures.has(signature(sc))) {
1687
- merged.push(sc);
1688
- report.scavenged++;
1689
- seenSignatures.add(signature(sc));
1690
- }
1691
- }
1692
- report.notes.push(...scavenged.notes);
1693
- for (const call of merged) {
1694
- const args = call.function?.arguments ?? "";
1695
- const r = repairTruncatedJson(args);
1696
- if (r.changed) {
1697
- call.function.arguments = r.repaired;
1698
- report.truncationsFixed++;
1699
- report.notes.push(...r.notes.map((n) => `[${call.function.name}] ${n}`));
1700
- }
1701
- }
1702
- const filtered = [];
1703
- for (const call of merged) {
1704
- const verdict = this.storm.inspect(call);
1705
- if (verdict.suppress) {
1706
- report.stormsBroken++;
1707
- if (verdict.reason) report.notes.push(verdict.reason);
1708
- continue;
1709
- }
1710
- filtered.push(call);
1711
- }
1712
- return { calls: filtered, report };
1713
- }
1714
- };
1715
- function signature(call) {
1716
- return `${call.function?.name ?? ""}::${call.function?.arguments ?? ""}`;
1717
- }
1718
-
1719
- // src/telemetry/stats.ts
1720
- var DEEPSEEK_PRICING = {
1721
- "deepseek-v4-flash": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
1722
- "deepseek-v4-pro": { inputCacheHit: 0.139, inputCacheMiss: 1.667, output: 3.333 },
1723
- // Compat aliases — priced as v4-flash per the deprecation notice.
1724
- "deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
1725
- "deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 }
1726
- };
1727
- var CLAUDE_SONNET_PRICING = { input: 3, output: 15 };
1728
- var DEEPSEEK_CONTEXT_TOKENS = {
1729
- "deepseek-v4-flash": 1e6,
1730
- "deepseek-v4-pro": 1e6,
1731
- "deepseek-chat": 1e6,
1732
- "deepseek-reasoner": 1e6
1733
- };
1734
- var DEFAULT_CONTEXT_TOKENS = 131072;
1735
- function costUsd(model, usage) {
1736
- const p = DEEPSEEK_PRICING[model];
1737
- if (!p) return 0;
1738
- return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss + usage.completionTokens * p.output) / 1e6;
1739
- }
1740
- function inputCostUsd(model, usage) {
1741
- const p = DEEPSEEK_PRICING[model];
1742
- if (!p) return 0;
1743
- return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss) / 1e6;
1744
- }
1745
- function outputCostUsd(model, usage) {
1746
- const p = DEEPSEEK_PRICING[model];
1747
- if (!p) return 0;
1748
- return usage.completionTokens * p.output / 1e6;
1749
- }
1750
- function cacheSavingsUsd(model, hitTokens) {
1751
- if (hitTokens <= 0) return 0;
1752
- const p = DEEPSEEK_PRICING[model];
1753
- if (!p) return 0;
1754
- return hitTokens * (p.inputCacheMiss - p.inputCacheHit) / 1e6;
1755
- }
1756
- function claudeEquivalentCost(usage) {
1757
- return (usage.promptTokens * CLAUDE_SONNET_PRICING.input + usage.completionTokens * CLAUDE_SONNET_PRICING.output) / 1e6;
1758
- }
1759
- var SessionStats = class {
1760
- turns = [];
1761
- record(turn, model, usage) {
1762
- const cost = costUsd(model, usage);
1763
- const stats = {
1764
- turn,
1765
- model,
1766
- usage,
1767
- cost,
1768
- cacheHitRatio: usage.cacheHitRatio
1769
- };
1770
- this.turns.push(stats);
1771
- return stats;
1772
- }
1773
- get totalCost() {
1774
- return this.turns.reduce((sum, t) => sum + t.cost, 0);
1775
- }
1776
- get totalClaudeEquivalent() {
1777
- return this.turns.reduce((sum, t) => sum + claudeEquivalentCost(t.usage), 0);
1778
- }
1779
- get savingsVsClaude() {
1780
- const c = this.totalClaudeEquivalent;
1781
- return c > 0 ? 1 - this.totalCost / c : 0;
1782
- }
1783
- get totalInputCost() {
1784
- return this.turns.reduce((sum, t) => sum + inputCostUsd(t.model, t.usage), 0);
1785
- }
1786
- get totalOutputCost() {
1787
- return this.turns.reduce((sum, t) => sum + outputCostUsd(t.model, t.usage), 0);
1949
+
1950
+ // src/repair/index.ts
1951
+ var ToolCallRepair = class {
1952
+ storm;
1953
+ opts;
1954
+ constructor(opts) {
1955
+ this.opts = opts;
1956
+ this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3, opts.isMutating);
1788
1957
  }
1789
- get aggregateCacheHitRatio() {
1790
- let hit = 0;
1791
- let miss = 0;
1792
- for (const t of this.turns) {
1793
- hit += t.usage.promptCacheHitTokens;
1794
- miss += t.usage.promptCacheMissTokens;
1795
- }
1796
- const denom = hit + miss;
1797
- return denom > 0 ? hit / denom : 0;
1958
+ /** Called at start of every user turn — fresh intent shouldn't inherit old repetition state. */
1959
+ resetStorm() {
1960
+ this.storm.reset();
1798
1961
  }
1799
- summary() {
1800
- const last = this.turns[this.turns.length - 1];
1801
- return {
1802
- turns: this.turns.length,
1803
- totalCostUsd: round(this.totalCost, 6),
1804
- totalInputCostUsd: round(this.totalInputCost, 6),
1805
- totalOutputCostUsd: round(this.totalOutputCost, 6),
1806
- claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1807
- savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1808
- cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1809
- lastPromptTokens: last?.usage.promptTokens ?? 0,
1810
- lastTurnCostUsd: round(last?.cost ?? 0, 6)
1962
+ process(declaredCalls, reasoningContent, content = null) {
1963
+ const report = {
1964
+ scavenged: 0,
1965
+ truncationsFixed: 0,
1966
+ stormsBroken: 0,
1967
+ notes: []
1811
1968
  };
1969
+ const combined = [reasoningContent ?? "", content ?? ""].filter(Boolean).join("\n");
1970
+ const scavenged = scavengeToolCalls(combined || null, {
1971
+ allowedNames: this.opts.allowedToolNames,
1972
+ maxCalls: this.opts.maxScavenge ?? 4
1973
+ });
1974
+ const seenSignatures = new Set(declaredCalls.map(signature));
1975
+ const merged = [...declaredCalls];
1976
+ for (const sc of scavenged.calls) {
1977
+ if (!seenSignatures.has(signature(sc))) {
1978
+ merged.push(sc);
1979
+ report.scavenged++;
1980
+ seenSignatures.add(signature(sc));
1981
+ }
1982
+ }
1983
+ report.notes.push(...scavenged.notes);
1984
+ for (const call of merged) {
1985
+ const args = call.function?.arguments ?? "";
1986
+ const r = repairTruncatedJson(args);
1987
+ if (r.changed) {
1988
+ call.function.arguments = r.repaired;
1989
+ report.truncationsFixed++;
1990
+ report.notes.push(...r.notes.map((n) => `[${call.function.name}] ${n}`));
1991
+ }
1992
+ }
1993
+ const filtered = [];
1994
+ for (const call of merged) {
1995
+ const verdict = this.storm.inspect(call);
1996
+ if (verdict.suppress) {
1997
+ report.stormsBroken++;
1998
+ if (verdict.reason) report.notes.push(verdict.reason);
1999
+ continue;
2000
+ }
2001
+ filtered.push(call);
2002
+ }
2003
+ return { calls: filtered, report };
1812
2004
  }
1813
2005
  };
1814
- function round(n, digits) {
1815
- const f = 10 ** digits;
1816
- return Math.round(n * f) / f;
2006
+ function signature(call) {
2007
+ return `${call.function?.name ?? ""}::${call.function?.arguments ?? ""}`;
1817
2008
  }
1818
2009
 
1819
2010
  // src/loop.ts
1820
- var ARGS_COMPACT_THRESHOLD_TOKENS = 800;
1821
- var TURN_END_RESULT_CAP_TOKENS = 3e3;
1822
2011
  var FAILURE_ESCALATION_THRESHOLD = 3;
1823
2012
  var ESCALATION_MODEL = "deepseek-v4-pro";
1824
2013
  var NEEDS_PRO_MARKER_PREFIX = "<<<NEEDS_PRO";
@@ -1849,6 +2038,8 @@ var CacheFirstLoop = class {
1849
2038
  sessionName;
1850
2039
  hooks;
1851
2040
  hookCwd;
2041
+ /** PauseGate bridge — defaults to singleton, injectable for tests. */
2042
+ confirmationGate;
1852
2043
  /** Number of messages that were pre-loaded from the session file. */
1853
2044
  resumedMessageCount;
1854
2045
  _turn = 0;
@@ -1860,6 +2051,8 @@ var CacheFirstLoop = class {
1860
2051
  _turnFailureCount = 0;
1861
2052
  _turnFailureTypes = {};
1862
2053
  _turnSelfCorrected = false;
2054
+ _foldedThisTurn = false;
2055
+ context;
1863
2056
  constructor(opts) {
1864
2057
  this.client = opts.client;
1865
2058
  this.prefix = opts.prefix;
@@ -1871,6 +2064,7 @@ var CacheFirstLoop = class {
1871
2064
  this.maxToolIters = opts.maxToolIters ?? 64;
1872
2065
  this.hooks = opts.hooks ?? [];
1873
2066
  this.hookCwd = opts.hookCwd ?? process.cwd();
2067
+ this.confirmationGate = opts.confirmationGate ?? pauseGate;
1874
2068
  if (typeof opts.branch === "number") {
1875
2069
  this.branchOptions = { budget: opts.branch };
1876
2070
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1933,54 +2127,18 @@ var CacheFirstLoop = class {
1933
2127
  } else {
1934
2128
  this.resumedMessageCount = 0;
1935
2129
  }
2130
+ this.context = new ContextManager({
2131
+ client: this.client,
2132
+ log: this.log,
2133
+ stats: this.stats,
2134
+ sessionName: this.sessionName,
2135
+ getAbortSignal: () => this._turnAbort.signal,
2136
+ getCurrentTurn: () => this._turn
2137
+ });
1936
2138
  }
1937
- /** Shrink huge edit_file/write_file args post-dispatch tool result already explains. */
1938
- compactToolCallArgsAfterResponse() {
1939
- const before = this.log.toMessages();
1940
- const { messages, healedCount } = shrinkOversizedToolCallArgsByTokens(
1941
- before,
1942
- ARGS_COMPACT_THRESHOLD_TOKENS
1943
- );
1944
- if (healedCount === 0) return;
1945
- this.log.compactInPlace(messages);
1946
- if (this.sessionName) {
1947
- try {
1948
- rewriteSession(this.sessionName, messages);
1949
- } catch {
1950
- }
1951
- }
1952
- }
1953
- /** Preventive end-of-turn shrink — trim big results before they ride into the next prompt. */
1954
- autoCompactToolResultsOnTurnEnd() {
1955
- const before = this.log.toMessages();
1956
- const shrunk = shrinkOversizedToolResultsByTokens(before, TURN_END_RESULT_CAP_TOKENS);
1957
- if (shrunk.healedCount === 0) return;
1958
- this.log.compactInPlace(shrunk.messages);
1959
- if (this.sessionName) {
1960
- try {
1961
- rewriteSession(this.sessionName, shrunk.messages);
1962
- } catch {
1963
- }
1964
- }
1965
- }
1966
- compact(maxTokens = 4e3) {
1967
- const before = this.log.toMessages();
1968
- const resultsPass = shrinkOversizedToolResultsByTokens(before, maxTokens);
1969
- const argsPass = shrinkOversizedToolCallArgsByTokens(resultsPass.messages, maxTokens);
1970
- const messages = argsPass.messages;
1971
- const healedCount = resultsPass.healedCount + argsPass.healedCount;
1972
- const tokensSaved = resultsPass.tokensSaved + argsPass.tokensSaved;
1973
- const charsSaved = resultsPass.charsSaved + argsPass.charsSaved;
1974
- if (healedCount > 0) {
1975
- this.log.compactInPlace(messages);
1976
- if (this.sessionName) {
1977
- try {
1978
- rewriteSession(this.sessionName, messages);
1979
- } catch {
1980
- }
1981
- }
1982
- }
1983
- return { healedCount, tokensSaved, charsSaved };
2139
+ /** Replace older turns with one summary message; keep tail within keepRecentTokens budget. */
2140
+ async compactHistory(opts) {
2141
+ return this.context.fold(this.model, opts);
1984
2142
  }
1985
2143
  appendAndPersist(message) {
1986
2144
  this.log.append(message);
@@ -2178,6 +2336,7 @@ var CacheFirstLoop = class {
2178
2336
  this._turnFailureTypes = {};
2179
2337
  this._turnSelfCorrected = false;
2180
2338
  this._escalateThisTurn = false;
2339
+ this._foldedThisTurn = false;
2181
2340
  let armedConsumed = false;
2182
2341
  if (this._proArmedForNextTurn) {
2183
2342
  this._escalateThisTurn = true;
@@ -2214,7 +2373,6 @@ var CacheFirstLoop = class {
2214
2373
  content: stoppedMsg,
2215
2374
  forcedSummary: true
2216
2375
  };
2217
- this.autoCompactToolResultsOnTurnEnd();
2218
2376
  yield { turn: this._turn, role: "done", content: stoppedMsg };
2219
2377
  this._turnAbort = new AbortController();
2220
2378
  return;
@@ -2236,26 +2394,31 @@ var CacheFirstLoop = class {
2236
2394
  }
2237
2395
  let messages = this.buildMessages(pendingUser);
2238
2396
  {
2239
- const ctxMax2 = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
2240
- const estimate = estimateRequestTokens(messages, this.prefix.toolSpecs);
2241
- if (estimate / ctxMax2 > 0.95) {
2242
- const result = this.compact(1e3);
2243
- if (result.healedCount > 0) {
2397
+ const decision2 = this.context.decidePreflight(messages, this.prefix.toolSpecs, this.model);
2398
+ if (decision2.needsAction) {
2399
+ const { estimateTokens: estimate, ctxMax } = decision2;
2400
+ yield {
2401
+ turn: this._turn,
2402
+ role: "status",
2403
+ content: "preflight: context near full, attempting fold\u2026"
2404
+ };
2405
+ const result = await this.context.fold(this.model);
2406
+ if (result.folded) {
2244
2407
  yield {
2245
2408
  turn: this._turn,
2246
2409
  role: "warning",
2247
- content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax2.toLocaleString()} tokens (${Math.round(
2248
- estimate / ctxMax2 * 100
2249
- )}%) \u2014 pre-compacted ${result.healedCount} tool result(s), saved ${result.tokensSaved.toLocaleString()} tokens. Sending.`
2410
+ content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
2411
+ estimate / ctxMax * 100
2412
+ )}%) \u2014 folded ${result.beforeMessages} messages \u2192 ${result.afterMessages} (summary ${result.summaryChars} chars). Sending.`
2250
2413
  };
2251
2414
  messages = this.buildMessages(pendingUser);
2252
2415
  } else {
2253
2416
  yield {
2254
2417
  turn: this._turn,
2255
2418
  role: "warning",
2256
- content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax2.toLocaleString()} tokens (${Math.round(
2257
- estimate / ctxMax2 * 100
2258
- )}%) and nothing to auto-compact \u2014 DeepSeek will likely 400. Run /forget or /clear to start fresh.`
2419
+ content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
2420
+ estimate / ctxMax * 100
2421
+ )}%) and nothing left to fold \u2014 DeepSeek will likely 400. Run /clear or /new to start fresh.`
2259
2422
  };
2260
2423
  }
2261
2424
  }
@@ -2450,7 +2613,6 @@ var CacheFirstLoop = class {
2450
2613
  }
2451
2614
  } catch (err) {
2452
2615
  if (signal.aborted) {
2453
- this.autoCompactToolResultsOnTurnEnd();
2454
2616
  yield { turn: this._turn, role: "done", content: "" };
2455
2617
  this._turnAbort = new AbortController();
2456
2618
  return;
@@ -2568,60 +2730,43 @@ var CacheFirstLoop = class {
2568
2730
  yield* this.forceSummaryAfterIterLimit({ reason: "stuck" });
2569
2731
  return;
2570
2732
  }
2571
- this.autoCompactToolResultsOnTurnEnd();
2572
2733
  yield { turn: this._turn, role: "done", content: assistantContent };
2573
2734
  return;
2574
2735
  }
2575
- const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
2576
- if (usage) {
2577
- const ratio = usage.promptTokens / ctxMax;
2578
- if (ratio > 0.4 && ratio <= 0.8) {
2579
- const before = usage.promptTokens;
2580
- const soft = this.compact(4e3);
2581
- if (soft.healedCount > 0) {
2582
- const after = Math.max(0, before - soft.tokensSaved);
2583
- yield {
2584
- turn: this._turn,
2585
- role: "warning",
2586
- content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
2587
- ratio * 100
2588
- )}%) \u2014 proactively compacted ${soft.healedCount} tool result(s) to 4k tokens, saved ${soft.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Staying ahead of the 80% guard.`
2589
- };
2590
- }
2591
- }
2592
- }
2593
- if (usage && usage.promptTokens / ctxMax > 0.8) {
2594
- const before = usage.promptTokens;
2595
- const compactResult = this.compact(1e3);
2596
- if (compactResult.healedCount > 0) {
2597
- const after = Math.max(0, before - compactResult.tokensSaved);
2598
- yield {
2599
- turn: this._turn,
2600
- role: "warning",
2601
- content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} \u2014 auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ${compactResult.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`
2602
- };
2603
- } else {
2736
+ const decision = this.context.decideAfterUsage(usage, this.model, this._foldedThisTurn);
2737
+ if (decision.kind === "fold") {
2738
+ this._foldedThisTurn = true;
2739
+ const before = decision.promptTokens;
2740
+ const ctxMax = decision.ctxMax;
2741
+ const aggressiveTag = decision.aggressive ? " (aggressive)" : "";
2742
+ yield {
2743
+ turn: this._turn,
2744
+ role: "status",
2745
+ content: `compacting history${aggressiveTag}\u2026`
2746
+ };
2747
+ const result = await this.compactHistory({ keepRecentTokens: decision.tailBudget });
2748
+ if (result.folded) {
2604
2749
  yield {
2605
2750
  turn: this._turn,
2606
2751
  role: "warning",
2607
2752
  content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
2608
2753
  before / ctxMax * 100
2609
- )}%) \u2014 nothing to auto-compact. Forcing summary from what was gathered.`
2754
+ )}%) \u2014 ${decision.aggressive ? "aggressively folded" : "folded"} ${result.beforeMessages} messages \u2192 ${result.afterMessages} (summary ${result.summaryChars} chars). Continuing.`
2610
2755
  };
2611
- const tail = this.log.entries[this.log.entries.length - 1];
2612
- if (tail && tail.role === "assistant" && Array.isArray(tail.tool_calls) && tail.tool_calls.length > 0) {
2613
- const kept = this.log.entries.slice(0, -1);
2614
- this.log.compactInPlace([...kept]);
2615
- if (this.sessionName) {
2616
- try {
2617
- rewriteSession(this.sessionName, kept);
2618
- } catch {
2619
- }
2620
- }
2621
- }
2622
- yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
2623
- return;
2624
2756
  }
2757
+ } else if (decision.kind === "exit-with-summary") {
2758
+ const before = decision.promptTokens;
2759
+ const ctxMax = decision.ctxMax;
2760
+ yield {
2761
+ turn: this._turn,
2762
+ role: "warning",
2763
+ content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
2764
+ before / ctxMax * 100
2765
+ )}%) \u2014 forcing summary from what was gathered. Run /compact, /clear, or /new to reset.`
2766
+ };
2767
+ this.context.trimTrailingToolCalls();
2768
+ yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
2769
+ return;
2625
2770
  }
2626
2771
  for (const call of repairedCalls) {
2627
2772
  const name = call.function?.name ?? "";
@@ -2653,7 +2798,8 @@ ${reason}`;
2653
2798
  } else {
2654
2799
  result = await this.tools.dispatch(name, args, {
2655
2800
  signal,
2656
- maxResultTokens: DEFAULT_MAX_RESULT_TOKENS
2801
+ maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
2802
+ confirmationGate: this.confirmationGate
2657
2803
  });
2658
2804
  const postReport = await runHooks({
2659
2805
  hooks: this.hooks,
@@ -2673,7 +2819,6 @@ ${reason}`;
2673
2819
  name,
2674
2820
  content: result
2675
2821
  });
2676
- this.compactToolCallArgsAfterResponse();
2677
2822
  if (this.noteToolFailureSignal(result)) {
2678
2823
  yield {
2679
2824
  turn: this._turn,
@@ -2732,7 +2877,6 @@ ${summary}`;
2732
2877
  stats: summaryStats,
2733
2878
  forcedSummary: true
2734
2879
  };
2735
- this.autoCompactToolResultsOnTurnEnd();
2736
2880
  yield { turn: this._turn, role: "done", content: summary };
2737
2881
  } catch (err) {
2738
2882
  const label = errorLabelFor(opts.reason, this.maxToolIters);
@@ -2742,7 +2886,6 @@ ${summary}`;
2742
2886
  content: "",
2743
2887
  error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
2744
2888
  };
2745
- this.autoCompactToolResultsOnTurnEnd();
2746
2889
  yield { turn: this._turn, role: "done", content: "" };
2747
2890
  }
2748
2891
  }
@@ -2871,56 +3014,6 @@ function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
2871
3014
  });
2872
3015
  return { messages: out, healedCount, tokensSaved, charsSaved };
2873
3016
  }
2874
- function shrinkOversizedToolCallArgsByTokens(messages, maxTokens) {
2875
- let healedCount = 0;
2876
- let tokensSaved = 0;
2877
- let charsSaved = 0;
2878
- const out = messages.map((msg) => {
2879
- if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
2880
- let changed = false;
2881
- const newCalls = msg.tool_calls.map((call) => {
2882
- const args = call.function?.arguments;
2883
- if (typeof args !== "string" || args.length <= maxTokens) return call;
2884
- const beforeTokens = countTokens(args);
2885
- if (beforeTokens <= maxTokens) return call;
2886
- const shrunk = shrinkJsonLongStrings(args);
2887
- const afterTokens = countTokens(shrunk);
2888
- if (afterTokens >= beforeTokens) return call;
2889
- changed = true;
2890
- healedCount += 1;
2891
- tokensSaved += beforeTokens - afterTokens;
2892
- charsSaved += args.length - shrunk.length;
2893
- return { ...call, function: { ...call.function, arguments: shrunk } };
2894
- });
2895
- if (!changed) return msg;
2896
- return { ...msg, tool_calls: newCalls };
2897
- });
2898
- return { messages: out, healedCount, tokensSaved, charsSaved };
2899
- }
2900
- function shrinkJsonLongStrings(jsonStr) {
2901
- let parsed;
2902
- try {
2903
- parsed = JSON.parse(jsonStr);
2904
- } catch {
2905
- const head = jsonStr.slice(0, 200);
2906
- return `${head}\u2026[shrunk: ${jsonStr.length} chars, unparsed]`;
2907
- }
2908
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2909
- return jsonStr;
2910
- }
2911
- const LONG_THRESHOLD = 300;
2912
- const input = parsed;
2913
- const output = {};
2914
- for (const [k, v] of Object.entries(input)) {
2915
- if (typeof v === "string" && v.length > LONG_THRESHOLD) {
2916
- const newlines = v.match(/\n/g)?.length ?? 0;
2917
- output[k] = `[\u2026shrunk: ${v.length} chars, ${newlines} lines \u2014 tool already responded, see result]`;
2918
- } else {
2919
- output[k] = v;
2920
- }
2921
- }
2922
- return JSON.stringify(output);
2923
- }
2924
3017
  function fixToolCallPairing(messages) {
2925
3018
  const out = [];
2926
3019
  let droppedAssistantCalls = 0;
@@ -4876,7 +4969,7 @@ function registerChoiceTool(registry, opts = {}) {
4876
4969
  },
4877
4970
  required: ["question", "options"]
4878
4971
  },
4879
- fn: async (args) => {
4972
+ fn: async (args, ctx) => {
4880
4973
  const question = (args?.question ?? "").trim();
4881
4974
  if (!question) {
4882
4975
  throw new Error(
@@ -4896,7 +4989,13 @@ function registerChoiceTool(registry, opts = {}) {
4896
4989
  }
4897
4990
  const allowCustom = args?.allowCustom === true;
4898
4991
  opts.onChoiceRequested?.(question, options);
4899
- throw new ChoiceRequestedError(question, options, allowCustom);
4992
+ const verdict = await (ctx?.confirmationGate ?? pauseGate).ask({
4993
+ kind: "choice",
4994
+ payload: { question, options, allowCustom }
4995
+ });
4996
+ if (verdict.type === "pick") return `user picked: ${verdict.optionId}`;
4997
+ if (verdict.type === "text") return `user answered: ${verdict.text}`;
4998
+ return "user cancelled the choice";
4900
4999
  }
4901
5000
  });
4902
5001
  return registry;
@@ -5013,7 +5112,7 @@ function registerSubmitPlan(registry, opts) {
5013
5112
  },
5014
5113
  required: ["plan"]
5015
5114
  },
5016
- fn: async (args) => {
5115
+ fn: async (args, ctx) => {
5017
5116
  const plan = (args?.plan ?? "").trim();
5018
5117
  if (!plan) {
5019
5118
  throw new Error("submit_plan: empty plan \u2014 write a markdown plan and try again.");
@@ -5021,7 +5120,13 @@ function registerSubmitPlan(registry, opts) {
5021
5120
  const steps = sanitizeSteps(args?.steps);
5022
5121
  const summary = typeof args?.summary === "string" ? args.summary.trim() || void 0 : void 0;
5023
5122
  opts.onPlanSubmitted?.(plan, steps);
5024
- throw new PlanProposedError(plan, steps, summary);
5123
+ const verdict = await (ctx?.confirmationGate ?? pauseGate).ask({
5124
+ kind: "plan_proposed",
5125
+ payload: { plan, steps, summary }
5126
+ });
5127
+ if (verdict.type === "approve") return "plan approved";
5128
+ if (verdict.type === "refine") throw new Error("user requested refinement");
5129
+ throw new Error("plan cancelled");
5025
5130
  }
5026
5131
  });
5027
5132
  }
@@ -5052,7 +5157,7 @@ function registerMarkStepComplete(registry, opts) {
5052
5157
  },
5053
5158
  required: ["stepId", "result"]
5054
5159
  },
5055
- fn: async (args) => {
5160
+ fn: async (args, ctx) => {
5056
5161
  const stepId = (args?.stepId ?? "").trim();
5057
5162
  const result = (args?.result ?? "").trim();
5058
5163
  if (!stepId) {
@@ -5069,7 +5174,16 @@ function registerMarkStepComplete(registry, opts) {
5069
5174
  if (title) update.title = title;
5070
5175
  if (notes) update.notes = notes;
5071
5176
  opts.onStepCompleted?.(update);
5072
- return update;
5177
+ const verdict = await (ctx?.confirmationGate ?? pauseGate).ask({
5178
+ kind: "plan_checkpoint",
5179
+ payload: { stepId, title, result, notes }
5180
+ });
5181
+ if (verdict.type === "continue") return JSON.stringify(update);
5182
+ if (verdict.type === "revise") {
5183
+ if (verdict.feedback) return `revision requested: ${verdict.feedback}`;
5184
+ throw new Error("user requested revision at checkpoint");
5185
+ }
5186
+ throw new Error("user stopped at checkpoint");
5073
5187
  }
5074
5188
  });
5075
5189
  }
@@ -5097,7 +5211,7 @@ function registerRevisePlan(registry, opts) {
5097
5211
  },
5098
5212
  required: ["reason", "remainingSteps"]
5099
5213
  },
5100
- fn: async (args) => {
5214
+ fn: async (args, ctx) => {
5101
5215
  const reason = (args?.reason ?? "").trim();
5102
5216
  if (!reason) {
5103
5217
  throw new Error(
@@ -5112,7 +5226,13 @@ function registerRevisePlan(registry, opts) {
5112
5226
  }
5113
5227
  const summary = typeof args?.summary === "string" ? args.summary.trim() || void 0 : void 0;
5114
5228
  opts.onPlanRevisionProposed?.(reason, remainingSteps, summary);
5115
- throw new PlanRevisionProposedError(reason, remainingSteps, summary);
5229
+ const verdict = await (ctx?.confirmationGate ?? pauseGate).ask({
5230
+ kind: "plan_revision",
5231
+ payload: { reason, remainingSteps, summary }
5232
+ });
5233
+ if (verdict.type === "accepted") return "revision accepted";
5234
+ if (verdict.type === "rejected") throw new Error("revision rejected");
5235
+ throw new Error("revision cancelled");
5116
5236
  }
5117
5237
  });
5118
5238
  }
@@ -5371,6 +5491,60 @@ import { spawn as spawn4, spawnSync } from "child_process";
5371
5491
  import { existsSync as existsSync8, statSync as statSync4 } from "fs";
5372
5492
  import * as pathMod4 from "path";
5373
5493
 
5494
+ // src/config.ts
5495
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "fs";
5496
+ import { homedir as homedir5 } from "os";
5497
+ import { dirname as dirname4, join as join9 } from "path";
5498
+ function defaultConfigPath() {
5499
+ return join9(homedir5(), ".reasonix", "config.json");
5500
+ }
5501
+ function readConfig(path2 = defaultConfigPath()) {
5502
+ try {
5503
+ const raw = readFileSync9(path2, "utf8");
5504
+ const parsed = JSON.parse(raw);
5505
+ if (parsed && typeof parsed === "object") return parsed;
5506
+ } catch {
5507
+ }
5508
+ return {};
5509
+ }
5510
+ function writeConfig(cfg, path2 = defaultConfigPath()) {
5511
+ mkdirSync3(dirname4(path2), { recursive: true });
5512
+ writeFileSync3(path2, JSON.stringify(cfg, null, 2), "utf8");
5513
+ try {
5514
+ chmodSync2(path2, 384);
5515
+ } catch {
5516
+ }
5517
+ }
5518
+ function loadApiKey(path2 = defaultConfigPath()) {
5519
+ if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
5520
+ return readConfig(path2).apiKey;
5521
+ }
5522
+ function saveApiKey(key, path2 = defaultConfigPath()) {
5523
+ const cfg = readConfig(path2);
5524
+ cfg.apiKey = key.trim();
5525
+ writeConfig(cfg, path2);
5526
+ }
5527
+ function addProjectShellAllowed(rootDir, prefix, path2 = defaultConfigPath()) {
5528
+ const trimmed = prefix.trim();
5529
+ if (!trimmed) return;
5530
+ const cfg = readConfig(path2);
5531
+ if (!cfg.projects) cfg.projects = {};
5532
+ if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
5533
+ const existing = cfg.projects[rootDir].shellAllowed ?? [];
5534
+ if (existing.includes(trimmed)) return;
5535
+ cfg.projects[rootDir].shellAllowed = [...existing, trimmed];
5536
+ writeConfig(cfg, path2);
5537
+ }
5538
+ function isPlausibleKey(key) {
5539
+ const trimmed = key.trim();
5540
+ return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
5541
+ }
5542
+ function redactKey(key) {
5543
+ if (!key) return "";
5544
+ if (key.length <= 12) return "****";
5545
+ return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
5546
+ }
5547
+
5374
5548
  // src/tools/jobs.ts
5375
5549
  import { spawn as spawn2 } from "child_process";
5376
5550
  import * as pathMod2 from "path";
@@ -6232,12 +6406,65 @@ function detectShellOperator(cmd) {
6232
6406
  if (quote) return null;
6233
6407
  return check();
6234
6408
  }
6409
+ var RISKY_ARGS = {
6410
+ // Branch / remote mutation
6411
+ "git branch": ["-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "--force"],
6412
+ "git remote": ["add", "remove", "rm", "rename", "set-url", "set-head", "prune"],
6413
+ // `--output` writes to an arbitrary path; `--ext-diff` invokes user-config'd external programs.
6414
+ "git diff": ["--output", "--ext-diff"],
6415
+ "git log": ["--output"],
6416
+ "git show": ["--output"],
6417
+ // `-exec*` / `-ok*` are RCE; `-delete` and `-fprint*` / `-fls` write to arbitrary paths.
6418
+ find: [
6419
+ "-delete",
6420
+ "-exec",
6421
+ "-execdir",
6422
+ "-ok",
6423
+ "-okdir",
6424
+ "-fprint",
6425
+ "-fprint0",
6426
+ "-fprintf",
6427
+ "-fls"
6428
+ ],
6429
+ // `-o FILE` writes the tree to an arbitrary path.
6430
+ tree: ["-o"],
6431
+ // Auto-fix mutates source files.
6432
+ "npx eslint": ["--fix", "--fix-dry-run"],
6433
+ "npx biome check": ["--write", "--apply", "--apply-unsafe"],
6434
+ ruff: ["--fix", "--unsafe-fixes", "format"]
6435
+ };
6436
+ function tailHasRisky(tail, risky) {
6437
+ for (const a of tail) {
6438
+ for (const r of risky) {
6439
+ if (a === r) return true;
6440
+ if (a.startsWith(`${r}=`)) return true;
6441
+ }
6442
+ }
6443
+ return false;
6444
+ }
6235
6445
  function isAllowed(cmd, extra = []) {
6236
- const normalized = cmd.trim().replace(/\s+/g, " ");
6446
+ let argv;
6447
+ try {
6448
+ argv = tokenizeCommand(cmd);
6449
+ } catch {
6450
+ return false;
6451
+ }
6452
+ if (argv.length === 0) return false;
6237
6453
  const allowlist = [...BUILTIN_ALLOWLIST, ...extra];
6238
6454
  for (const prefix of allowlist) {
6239
- if (normalized === prefix) return true;
6240
- if (normalized.startsWith(`${prefix} `)) return true;
6455
+ const prefixTokens = prefix.split(" ");
6456
+ if (argv.length < prefixTokens.length) continue;
6457
+ let match = true;
6458
+ for (let i = 0; i < prefixTokens.length; i++) {
6459
+ if (argv[i] !== prefixTokens[i]) {
6460
+ match = false;
6461
+ break;
6462
+ }
6463
+ }
6464
+ if (!match) continue;
6465
+ const risky = RISKY_ARGS[prefix];
6466
+ if (risky && tailHasRisky(argv.slice(prefixTokens.length), risky)) return false;
6467
+ return true;
6241
6468
  }
6242
6469
  return false;
6243
6470
  }
@@ -6498,7 +6725,16 @@ function registerShellTools(registry, opts) {
6498
6725
  const cmd = args.command.trim();
6499
6726
  if (!cmd) throw new Error("run_command: empty command");
6500
6727
  if (!isAllowAll() && !isCommandAllowed(cmd, getExtraAllowed())) {
6501
- throw new NeedsConfirmationError(cmd);
6728
+ const gate = ctx?.confirmationGate ?? pauseGate;
6729
+ const choice = await gate.ask({ kind: "run_command", payload: { command: cmd } });
6730
+ if (choice.type === "deny") {
6731
+ throw new Error(
6732
+ `user denied: ${cmd}${choice.denyContext ? ` \u2014 ${choice.denyContext}` : ""}`
6733
+ );
6734
+ }
6735
+ if (choice.type === "always_allow") {
6736
+ addProjectShellAllowed(rootDir, choice.prefix);
6737
+ }
6502
6738
  }
6503
6739
  const effectiveTimeout = Math.max(1, Math.min(600, args.timeoutSec ?? timeoutSec));
6504
6740
  const result = await runCommand(cmd, {
@@ -6530,8 +6766,17 @@ function registerShellTools(registry, opts) {
6530
6766
  fn: async (args, ctx) => {
6531
6767
  const cmd = args.command.trim();
6532
6768
  if (!cmd) throw new Error("run_background: empty command");
6533
- if (!isAllowAll() && !isAllowed(cmd, getExtraAllowed())) {
6534
- throw new NeedsConfirmationError(cmd);
6769
+ if (!isAllowAll() && !isCommandAllowed(cmd, getExtraAllowed())) {
6770
+ const gate = ctx?.confirmationGate ?? pauseGate;
6771
+ const choice = await gate.ask({ kind: "run_background", payload: { command: cmd } });
6772
+ if (choice.type === "deny") {
6773
+ throw new Error(
6774
+ `user denied: ${cmd}${choice.denyContext ? ` \u2014 ${choice.denyContext}` : ""}`
6775
+ );
6776
+ }
6777
+ if (choice.type === "always_allow") {
6778
+ addProjectShellAllowed(rootDir, choice.prefix);
6779
+ }
6535
6780
  }
6536
6781
  const result = await jobs.start(cmd, {
6537
6782
  cwd: rootDir,
@@ -6860,12 +7105,12 @@ ${i + 1}. ${r.title}`);
6860
7105
  }
6861
7106
 
6862
7107
  // src/env.ts
6863
- import { readFileSync as readFileSync9 } from "fs";
7108
+ import { readFileSync as readFileSync10 } from "fs";
6864
7109
  import { resolve as resolve8 } from "path";
6865
7110
  function loadDotenv(path2 = ".env") {
6866
7111
  let raw;
6867
7112
  try {
6868
- raw = readFileSync9(resolve8(process.cwd(), path2), "utf8");
7113
+ raw = readFileSync10(resolve8(process.cwd(), path2), "utf8");
6869
7114
  } catch {
6870
7115
  return;
6871
7116
  }
@@ -6884,7 +7129,7 @@ function loadDotenv(path2 = ".env") {
6884
7129
  }
6885
7130
 
6886
7131
  // src/transcript/log.ts
6887
- import { createWriteStream, readFileSync as readFileSync10 } from "fs";
7132
+ import { createWriteStream, readFileSync as readFileSync11 } from "fs";
6888
7133
  function recordFromLoopEvent(ev, extra) {
6889
7134
  const rec = {
6890
7135
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6935,7 +7180,7 @@ function openTranscriptFile(path2, meta) {
6935
7180
  return stream;
6936
7181
  }
6937
7182
  function readTranscript(path2) {
6938
- const raw = readFileSync10(path2, "utf8");
7183
+ const raw = readFileSync11(path2, "utf8");
6939
7184
  return parseTranscript(raw);
6940
7185
  }
6941
7186
  function isPlanStateEmptyShape(s) {
@@ -7382,25 +7627,25 @@ function truncate(s, n) {
7382
7627
  }
7383
7628
 
7384
7629
  // src/version.ts
7385
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
7386
- import { homedir as homedir5 } from "os";
7387
- import { dirname as dirname4, join as join9 } from "path";
7630
+ import { existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync12, writeFileSync as writeFileSync4 } from "fs";
7631
+ import { homedir as homedir6 } from "os";
7632
+ import { dirname as dirname5, join as join10 } from "path";
7388
7633
  import { fileURLToPath as fileURLToPath2 } from "url";
7389
7634
  var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
7390
7635
  var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
7391
7636
  var LATEST_FETCH_TIMEOUT_MS = 2e3;
7392
7637
  function readPackageVersion() {
7393
7638
  try {
7394
- let dir = dirname4(fileURLToPath2(import.meta.url));
7639
+ let dir = dirname5(fileURLToPath2(import.meta.url));
7395
7640
  for (let i = 0; i < 6; i++) {
7396
- const p = join9(dir, "package.json");
7641
+ const p = join10(dir, "package.json");
7397
7642
  if (existsSync9(p)) {
7398
- const pkg = JSON.parse(readFileSync11(p, "utf8"));
7643
+ const pkg = JSON.parse(readFileSync12(p, "utf8"));
7399
7644
  if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
7400
7645
  return pkg.version;
7401
7646
  }
7402
7647
  }
7403
- const parent = dirname4(dir);
7648
+ const parent = dirname5(dir);
7404
7649
  if (parent === dir) break;
7405
7650
  dir = parent;
7406
7651
  }
@@ -7410,11 +7655,11 @@ function readPackageVersion() {
7410
7655
  }
7411
7656
  var VERSION = readPackageVersion();
7412
7657
  function cachePath(homeDirOverride) {
7413
- return join9(homeDirOverride ?? homedir5(), ".reasonix", "version-cache.json");
7658
+ return join10(homeDirOverride ?? homedir6(), ".reasonix", "version-cache.json");
7414
7659
  }
7415
7660
  function readCache(homeDirOverride) {
7416
7661
  try {
7417
- const raw = readFileSync11(cachePath(homeDirOverride), "utf8");
7662
+ const raw = readFileSync12(cachePath(homeDirOverride), "utf8");
7418
7663
  const parsed = JSON.parse(raw);
7419
7664
  if (parsed && typeof parsed.version === "string" && typeof parsed.checkedAt === "number") {
7420
7665
  return parsed;
@@ -7426,8 +7671,8 @@ function readCache(homeDirOverride) {
7426
7671
  function writeCache(entry, homeDirOverride) {
7427
7672
  try {
7428
7673
  const p = cachePath(homeDirOverride);
7429
- mkdirSync3(dirname4(p), { recursive: true });
7430
- writeFileSync3(p, JSON.stringify(entry), "utf8");
7674
+ mkdirSync4(dirname5(p), { recursive: true });
7675
+ writeFileSync4(p, JSON.stringify(entry), "utf8");
7431
7676
  } catch {
7432
7677
  }
7433
7678
  }
@@ -7653,9 +7898,12 @@ var McpClient = class {
7653
7898
  signal.addEventListener("abort", abortHandler, { once: true });
7654
7899
  }
7655
7900
  });
7901
+ promise.catch(() => void 0);
7656
7902
  try {
7657
- await this.transport.send(frame);
7903
+ await Promise.race([this.transport.send(frame), promise.then(() => void 0)]);
7658
7904
  } catch (err) {
7905
+ const pending = this.pending.get(id);
7906
+ if (pending) clearTimeout(pending.timeout);
7659
7907
  this.pending.delete(id);
7660
7908
  if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
7661
7909
  throw err;
@@ -8223,8 +8471,8 @@ async function trySection(load) {
8223
8471
  }
8224
8472
 
8225
8473
  // src/code/edit-blocks.ts
8226
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync12, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
8227
- import { dirname as dirname5, resolve as resolve9 } from "path";
8474
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync13, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
8475
+ import { dirname as dirname6, resolve as resolve9 } from "path";
8228
8476
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
8229
8477
  function parseEditBlocks(text) {
8230
8478
  const out = [];
@@ -8262,11 +8510,11 @@ function applyEditBlock(block, rootDir) {
8262
8510
  message: "file does not exist; to create it, use an empty SEARCH block"
8263
8511
  };
8264
8512
  }
8265
- mkdirSync4(dirname5(absTarget), { recursive: true });
8266
- writeFileSync4(absTarget, block.replace, "utf8");
8513
+ mkdirSync5(dirname6(absTarget), { recursive: true });
8514
+ writeFileSync5(absTarget, block.replace, "utf8");
8267
8515
  return { path: block.path, status: "created" };
8268
8516
  }
8269
- const content = readFileSync12(absTarget, "utf8");
8517
+ const content = readFileSync13(absTarget, "utf8");
8270
8518
  if (searchEmpty) {
8271
8519
  return {
8272
8520
  path: block.path,
@@ -8286,7 +8534,7 @@ function applyEditBlock(block, rootDir) {
8286
8534
  };
8287
8535
  }
8288
8536
  const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
8289
- writeFileSync4(absTarget, replaced, "utf8");
8537
+ writeFileSync5(absTarget, replaced, "utf8");
8290
8538
  return { path: block.path, status: "applied" };
8291
8539
  } catch (err) {
8292
8540
  return { path: block.path, status: "error", message: err.message };
@@ -8308,7 +8556,7 @@ function snapshotBeforeEdits(blocks, rootDir) {
8308
8556
  continue;
8309
8557
  }
8310
8558
  try {
8311
- snapshots.push({ path: b.path, prevContent: readFileSync12(abs, "utf8") });
8559
+ snapshots.push({ path: b.path, prevContent: readFileSync13(abs, "utf8") });
8312
8560
  } catch {
8313
8561
  snapshots.push({ path: b.path, prevContent: null });
8314
8562
  }
@@ -8335,7 +8583,7 @@ function restoreSnapshots(snapshots, rootDir) {
8335
8583
  message: "removed (the edit had created it)"
8336
8584
  };
8337
8585
  }
8338
- writeFileSync4(abs, snap.prevContent, "utf8");
8586
+ writeFileSync5(abs, snap.prevContent, "utf8");
8339
8587
  return {
8340
8588
  path: snap.path,
8341
8589
  status: "applied",
@@ -8354,8 +8602,8 @@ function lineEndingOf(text) {
8354
8602
  }
8355
8603
 
8356
8604
  // src/code/prompt.ts
8357
- import { existsSync as existsSync11, readFileSync as readFileSync13 } from "fs";
8358
- import { join as join10 } from "path";
8605
+ import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
8606
+ import { join as join11 } from "path";
8359
8607
  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.
8360
8608
 
8361
8609
  # Cite or shut up \u2014 non-negotiable
@@ -8557,12 +8805,12 @@ If \`semantic_search\` returns nothing useful (low scores, off-topic), THEN fall
8557
8805
  function codeSystemPrompt(rootDir, opts = {}) {
8558
8806
  const base = opts.hasSemanticSearch ? `${CODE_SYSTEM_PROMPT}${SEMANTIC_SEARCH_ROUTING}` : CODE_SYSTEM_PROMPT;
8559
8807
  const withMemory = applyMemoryStack(base, rootDir);
8560
- const gitignorePath = join10(rootDir, ".gitignore");
8808
+ const gitignorePath = join11(rootDir, ".gitignore");
8561
8809
  let result = withMemory;
8562
8810
  if (existsSync11(gitignorePath)) {
8563
8811
  let content;
8564
8812
  try {
8565
- content = readFileSync13(gitignorePath, "utf8");
8813
+ content = readFileSync14(gitignorePath, "utf8");
8566
8814
  } catch {
8567
8815
  }
8568
8816
  if (content !== void 0) {
@@ -8592,49 +8840,6 @@ ${appendParts.join("\n\n")}`;
8592
8840
  return result;
8593
8841
  }
8594
8842
 
8595
- // src/config.ts
8596
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync14, writeFileSync as writeFileSync5 } from "fs";
8597
- import { homedir as homedir6 } from "os";
8598
- import { dirname as dirname6, join as join11 } from "path";
8599
- function defaultConfigPath() {
8600
- return join11(homedir6(), ".reasonix", "config.json");
8601
- }
8602
- function readConfig(path2 = defaultConfigPath()) {
8603
- try {
8604
- const raw = readFileSync14(path2, "utf8");
8605
- const parsed = JSON.parse(raw);
8606
- if (parsed && typeof parsed === "object") return parsed;
8607
- } catch {
8608
- }
8609
- return {};
8610
- }
8611
- function writeConfig(cfg, path2 = defaultConfigPath()) {
8612
- mkdirSync5(dirname6(path2), { recursive: true });
8613
- writeFileSync5(path2, JSON.stringify(cfg, null, 2), "utf8");
8614
- try {
8615
- chmodSync2(path2, 384);
8616
- } catch {
8617
- }
8618
- }
8619
- function loadApiKey(path2 = defaultConfigPath()) {
8620
- if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
8621
- return readConfig(path2).apiKey;
8622
- }
8623
- function saveApiKey(key, path2 = defaultConfigPath()) {
8624
- const cfg = readConfig(path2);
8625
- cfg.apiKey = key.trim();
8626
- writeConfig(cfg, path2);
8627
- }
8628
- function isPlausibleKey(key) {
8629
- const trimmed = key.trim();
8630
- return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
8631
- }
8632
- function redactKey(key) {
8633
- if (!key) return "";
8634
- if (key.length <= 12) return "****";
8635
- return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
8636
- }
8637
-
8638
8843
  // src/telemetry/usage.ts
8639
8844
  import {
8640
8845
  appendFileSync as appendFileSync2,