reasonix 0.27.2 → 0.27.3

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
@@ -116,7 +116,9 @@ var DeepSeekClient = class {
116
116
  );
117
117
  }
118
118
  this.apiKey = apiKey;
119
- this.baseUrl = (opts.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com").replace(/\/+$/, "");
119
+ let url = opts.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
120
+ while (url.endsWith("/")) url = url.slice(0, -1);
121
+ this.baseUrl = url;
120
122
  this.timeoutMs = opts.timeoutMs ?? 66e4;
121
123
  this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
122
124
  this.retry = opts.retry ?? {};
@@ -456,6 +458,7 @@ var PauseGate = class {
456
458
  _nextId = 0;
457
459
  _pending = /* @__PURE__ */ new Map();
458
460
  _listeners = /* @__PURE__ */ new Set();
461
+ _auditListener = null;
459
462
  /** Block until the user responds. Takes a named options object so the
460
463
  * kind and payload fields don't get confused at the call site. */
461
464
  ask(opts) {
@@ -482,8 +485,12 @@ var PauseGate = class {
482
485
  const p = this._pending.get(id);
483
486
  if (!p) return;
484
487
  this._pending.delete(id);
488
+ this.emitAuditEvent(p.request, data);
485
489
  p.resolve(data);
486
490
  }
491
+ setAuditListener(fn) {
492
+ this._auditListener = fn;
493
+ }
487
494
  /** Subscribe to new pause requests. Returns an unsubscribe function. */
488
495
  on(fn) {
489
496
  this._listeners.add(fn);
@@ -496,6 +503,43 @@ var PauseGate = class {
496
503
  for (const [, p] of this._pending) return p.request;
497
504
  return null;
498
505
  }
506
+ emitAuditEvent(request, data) {
507
+ if (!this._auditListener) return;
508
+ if (request.kind !== "run_command" && request.kind !== "run_background") return;
509
+ if (!data || typeof data !== "object") return;
510
+ const choice = data;
511
+ try {
512
+ switch (choice.type) {
513
+ case "run_once":
514
+ this._auditListener({
515
+ type: "tool.confirm.allow",
516
+ kind: request.kind,
517
+ payload: request.payload
518
+ });
519
+ break;
520
+ case "deny":
521
+ this._auditListener({
522
+ type: "tool.confirm.deny",
523
+ kind: request.kind,
524
+ payload: request.payload,
525
+ denyContext: choice.denyContext
526
+ });
527
+ break;
528
+ case "always_allow":
529
+ if (typeof choice.prefix !== "string") return;
530
+ this._auditListener({
531
+ type: "tool.confirm.always_allow",
532
+ kind: request.kind,
533
+ payload: request.payload,
534
+ prefix: choice.prefix
535
+ });
536
+ break;
537
+ default:
538
+ break;
539
+ }
540
+ } catch {
541
+ }
542
+ }
499
543
  };
500
544
  var pauseGate = new PauseGate();
501
545
 
@@ -954,6 +998,7 @@ var ToolRegistry = class {
954
998
  _autoFlatten;
955
999
  _planMode = false;
956
1000
  _interceptor = null;
1001
+ _auditListener = null;
957
1002
  constructor(opts = {}) {
958
1003
  this._autoFlatten = opts.autoFlatten !== false;
959
1004
  }
@@ -969,6 +1014,9 @@ var ToolRegistry = class {
969
1014
  setToolInterceptor(fn) {
970
1015
  this._interceptor = fn;
971
1016
  }
1017
+ setAuditListener(fn) {
1018
+ this._auditListener = fn;
1019
+ }
972
1020
  register(def) {
973
1021
  if (!def.name) throw new Error("tool requires a name");
974
1022
  const internal = { ...def };
@@ -1041,6 +1089,10 @@ var ToolRegistry = class {
1041
1089
  }
1042
1090
  }
1043
1091
  try {
1092
+ try {
1093
+ this._auditListener?.({ name, args });
1094
+ } catch {
1095
+ }
1044
1096
  const result = await tool.fn(args, {
1045
1097
  signal: opts.signal,
1046
1098
  confirmationGate: opts.confirmationGate
@@ -1618,169 +1670,513 @@ var ContextManager = class {
1618
1670
  }
1619
1671
  };
1620
1672
 
1621
- // src/memory/runtime.ts
1622
- import { createHash } from "crypto";
1623
- var ImmutablePrefix = class {
1624
- system;
1625
- /** Each `addTool` costs one cache-miss turn — DeepSeek's prefix cache is keyed by full tool list. */
1626
- _toolSpecs;
1627
- fewShots;
1628
- /** Invalidated only via `addTool`; bypassing it leaves cache stale → fingerprint diverges from sent prefix. */
1629
- _fingerprintCache = null;
1630
- constructor(opts) {
1631
- this.system = opts.system;
1632
- this._toolSpecs = [...opts.toolSpecs ?? []];
1633
- this.fewShots = Object.freeze([...opts.fewShots ?? []]);
1634
- }
1635
- get toolSpecs() {
1636
- return this._toolSpecs;
1637
- }
1638
- toMessages() {
1639
- return [{ role: "system", content: this.system }, ...this.fewShots.map((m) => ({ ...m }))];
1673
+ // src/loop/branch.ts
1674
+ function summarizeBranch(chosen, samples) {
1675
+ return {
1676
+ budget: samples.length,
1677
+ chosenIndex: chosen.index,
1678
+ uncertainties: samples.map((s) => s.planState.uncertainties.length),
1679
+ temperatures: samples.map((s) => s.temperature)
1680
+ };
1681
+ }
1682
+
1683
+ // src/loop/errors.ts
1684
+ function formatLoopError(err) {
1685
+ const msg = err.message ?? "";
1686
+ if (msg.includes("maximum context length")) {
1687
+ const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
1688
+ const requested = reqMatch ? `${Number(reqMatch[1]).toLocaleString()} tokens` : "too many tokens";
1689
+ return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load \u2014 a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
1640
1690
  }
1641
- tools() {
1642
- return this._toolSpecs.map((t) => structuredClone(t));
1691
+ const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
1692
+ if (!m) return msg;
1693
+ const status = m[1] ?? "";
1694
+ const body = m[2] ?? "";
1695
+ const inner = extractDeepSeekErrorMessage(body);
1696
+ if (status === "401") {
1697
+ return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
1643
1698
  }
1644
- addTool(spec) {
1645
- const name = spec.function?.name;
1646
- if (!name) return false;
1647
- if (this._toolSpecs.some((t) => t.function?.name === name)) return false;
1648
- this._toolSpecs.push(spec);
1649
- this._fingerprintCache = null;
1650
- return true;
1699
+ if (status === "402") {
1700
+ return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up \u2014 the panel header shows your balance once it's non-zero.`;
1651
1701
  }
1652
- /** Mirror of addTool for MCP hot-unbridge. Same cache-miss cost — prefix changes shape. */
1653
- removeTool(name) {
1654
- const idx = this._toolSpecs.findIndex((t) => t.function?.name === name);
1655
- if (idx < 0) return false;
1656
- this._toolSpecs.splice(idx, 1);
1657
- this._fingerprintCache = null;
1658
- return true;
1702
+ if (status === "422") {
1703
+ return `Invalid parameter (DeepSeek 422): ${inner}`;
1659
1704
  }
1660
- get fingerprint() {
1661
- if (this._fingerprintCache !== null) return this._fingerprintCache;
1662
- this._fingerprintCache = this.computeFingerprint();
1663
- return this._fingerprintCache;
1705
+ if (status === "400") {
1706
+ return `Bad request (DeepSeek 400): ${inner}`;
1664
1707
  }
1665
- /** Dev/test only — throws on cache drift, which always means a non-`addTool` mutation slipped in. */
1666
- verifyFingerprint() {
1667
- const fresh = this.computeFingerprint();
1668
- if (this._fingerprintCache !== null && this._fingerprintCache !== fresh) {
1669
- throw new Error(
1670
- `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.`
1671
- );
1672
- }
1673
- this._fingerprintCache = fresh;
1674
- return fresh;
1708
+ return msg;
1709
+ }
1710
+ function reasonPrefixFor(reason, iterCap) {
1711
+ if (reason === "aborted") return "[aborted by user (Esc) \u2014 summarizing what I found so far]";
1712
+ if (reason === "context-guard") {
1713
+ return "[context budget running low \u2014 summarizing before the next call would overflow]";
1675
1714
  }
1676
- computeFingerprint() {
1677
- const blob = JSON.stringify({
1678
- system: this.system,
1679
- tools: this._toolSpecs,
1680
- shots: this.fewShots
1681
- });
1682
- return createHash("sha256").update(blob).digest("hex").slice(0, 16);
1715
+ if (reason === "stuck") {
1716
+ return "[stuck on a repeated tool call \u2014 explaining what was tried and what's blocking progress]";
1683
1717
  }
1684
- };
1685
- var AppendOnlyLog = class {
1686
- _entries = [];
1687
- append(message) {
1688
- if (!message || typeof message !== "object" || !("role" in message)) {
1689
- throw new Error(`invalid log entry: ${JSON.stringify(message)}`);
1718
+ return `[tool-call budget (${iterCap}) reached \u2014 forcing summary from what I found]`;
1719
+ }
1720
+ function errorLabelFor(reason, iterCap) {
1721
+ if (reason === "aborted") return "aborted by user";
1722
+ if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
1723
+ if (reason === "stuck") return "stuck (repeated tool call suppressed by storm-breaker)";
1724
+ return `tool-call budget (${iterCap}) reached`;
1725
+ }
1726
+ function extractDeepSeekErrorMessage(body) {
1727
+ const trimmed = body.trim();
1728
+ if (!trimmed) return "(no message)";
1729
+ try {
1730
+ const parsed = JSON.parse(trimmed);
1731
+ if (parsed && typeof parsed === "object") {
1732
+ const obj = parsed;
1733
+ if (obj.error && typeof obj.error.message === "string") return obj.error.message;
1734
+ if (typeof obj.message === "string") return obj.message;
1690
1735
  }
1691
- this._entries.push(message);
1692
- }
1693
- extend(messages) {
1694
- for (const m of messages) this.append(m);
1695
- }
1696
- /** The one append-only-breaking path — reserved for `/compact` + recovery. Use `append()` otherwise. */
1697
- compactInPlace(replacement) {
1698
- this._entries = [...replacement];
1699
- }
1700
- get entries() {
1701
- return this._entries;
1702
- }
1703
- toMessages() {
1704
- return this._entries.map((e) => ({ ...e }));
1705
- }
1706
- get length() {
1707
- return this._entries.length;
1708
- }
1709
- };
1710
- var VolatileScratch = class {
1711
- reasoning = null;
1712
- planState = null;
1713
- notes = [];
1714
- reset() {
1715
- this.reasoning = null;
1716
- this.planState = null;
1717
- this.notes = [];
1736
+ } catch {
1718
1737
  }
1719
- };
1738
+ return trimmed;
1739
+ }
1720
1740
 
1721
- // src/repair/scavenge.ts
1722
- function scavengeToolCalls(reasoningContent, opts) {
1723
- if (!reasoningContent) return { calls: [], notes: [] };
1724
- const max = opts.maxCalls ?? 4;
1725
- const notes = [];
1726
- const out = [];
1727
- for (const invoke of iterateDsmlInvokes(reasoningContent)) {
1728
- if (out.length >= max) break;
1729
- if (!opts.allowedNames.has(invoke.name)) continue;
1730
- out.push({
1731
- function: {
1732
- name: invoke.name,
1733
- arguments: JSON.stringify(invoke.args)
1734
- }
1735
- });
1736
- notes.push(`scavenged DSML call: ${invoke.name}`);
1737
- }
1738
- const nonDsml = stripDsmlBlocks(reasoningContent);
1739
- for (const candidate of iterateJsonObjects(nonDsml)) {
1740
- if (out.length >= max) break;
1741
- const call = coerceToToolCall(candidate, opts.allowedNames);
1742
- if (call) {
1743
- out.push(call);
1744
- notes.push(`scavenged call: ${call.function.name}`);
1745
- }
1746
- }
1747
- return { calls: out, notes };
1741
+ // src/loop/escalation.ts
1742
+ var NEEDS_PRO_MARKER_PREFIX = "<<<NEEDS_PRO";
1743
+ var NEEDS_PRO_MARKER_RE = /^<<<NEEDS_PRO(?::\s*([^>]*))?>>>/;
1744
+ var NEEDS_PRO_BUFFER_CHARS = 256;
1745
+ function parseEscalationMarker(content) {
1746
+ const m = NEEDS_PRO_MARKER_RE.exec(content.trimStart());
1747
+ if (!m) return { matched: false };
1748
+ const reason = m[1]?.trim();
1749
+ return { matched: true, reason: reason || void 0 };
1750
+ }
1751
+ function isEscalationRequest(content) {
1752
+ return parseEscalationMarker(content).matched;
1753
+ }
1754
+ function looksLikePartialEscalationMarker(buf) {
1755
+ const t = buf.trimStart();
1756
+ if (t.length === 0) return true;
1757
+ if (t.length <= NEEDS_PRO_MARKER_PREFIX.length) {
1758
+ return NEEDS_PRO_MARKER_PREFIX.startsWith(t);
1759
+ }
1760
+ if (!t.startsWith(NEEDS_PRO_MARKER_PREFIX)) return false;
1761
+ const rest = t.slice(NEEDS_PRO_MARKER_PREFIX.length);
1762
+ if (rest[0] !== ">" && rest[0] !== ":") return false;
1763
+ return true;
1748
1764
  }
1749
- function stripDsmlBlocks(text) {
1750
- let out = text;
1751
- out = out.replace(/<[||]DSML[||]function_calls>[\s\S]*?<\/?[||]DSML[||]function_calls>/g, "");
1752
- out = out.replace(/<[||]DSML[||]invoke\s+[^>]*>[\s\S]*?<\/[||]DSML[||]invoke>/g, "");
1753
- return out;
1765
+
1766
+ // src/loop/thinking.ts
1767
+ function isThinkingModeModel(model) {
1768
+ if (model.includes("reasoner")) return true;
1769
+ if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
1770
+ return false;
1754
1771
  }
1755
- function* iterateDsmlInvokes(text) {
1756
- const INVOKE_RE = /<[||]DSML[||]invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]DSML[||]invoke>/g;
1757
- for (const match of text.matchAll(INVOKE_RE)) {
1758
- const name = match[1];
1759
- const body = match[2];
1760
- if (!name || body === void 0) continue;
1761
- yield { name, args: parseDsmlParameters(body) };
1772
+ function thinkingModeForModel(model) {
1773
+ if (model === "deepseek-chat") return "disabled";
1774
+ if (model.includes("reasoner")) return "enabled";
1775
+ if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
1776
+ return void 0;
1777
+ }
1778
+ function stripHallucinatedToolMarkup(s) {
1779
+ let out = s;
1780
+ out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
1781
+ out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
1782
+ out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
1783
+ out = out.replace(/<|DSML|[\s\S]*$/g, "");
1784
+ return out.trim();
1785
+ }
1786
+
1787
+ // src/loop/messages.ts
1788
+ function buildAssistantMessage(content, toolCalls, producingModel, reasoningContent) {
1789
+ const msg = { role: "assistant", content };
1790
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
1791
+ if (isThinkingModeModel(producingModel) || reasoningContent && reasoningContent.length > 0) {
1792
+ msg.reasoning_content = reasoningContent ?? "";
1762
1793
  }
1794
+ return msg;
1763
1795
  }
1764
- function parseDsmlParameters(body) {
1765
- const PARAM_RE = /<[||]DSML[||]parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/[||]DSML[||]parameter>/g;
1766
- const args = {};
1767
- for (const m of body.matchAll(PARAM_RE)) {
1768
- const key = m[1];
1769
- const stringFlag = m[2];
1770
- const raw = (m[3] ?? "").trim();
1771
- if (!key) continue;
1772
- if (stringFlag === "false") {
1773
- try {
1774
- args[key] = JSON.parse(raw);
1775
- continue;
1776
- } catch {
1777
- }
1778
- }
1779
- args[key] = raw;
1796
+ function buildSyntheticAssistantMessage(content, fallbackModel) {
1797
+ return buildAssistantMessage(content, [], fallbackModel, "");
1798
+ }
1799
+
1800
+ // src/loop/force-summary.ts
1801
+ async function* forceSummaryAfterIterLimit(ctx, opts = { reason: "budget" }) {
1802
+ try {
1803
+ yield { turn: ctx.turn, role: "status", content: "summarizing what was gathered\u2026" };
1804
+ const messages = ctx.buildMessages();
1805
+ messages.push({
1806
+ role: "user",
1807
+ content: "I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks \u2014 they will be silently discarded. Just plain text."
1808
+ });
1809
+ const summaryModel = "deepseek-v4-flash";
1810
+ const summaryEffort = "high";
1811
+ const resp = await ctx.client.chat({
1812
+ model: summaryModel,
1813
+ messages,
1814
+ signal: ctx.signal,
1815
+ thinking: thinkingModeForModel(summaryModel),
1816
+ reasoningEffort: summaryEffort
1817
+ });
1818
+ const rawContent = resp.content?.trim() ?? "";
1819
+ const cleaned = stripHallucinatedToolMarkup(rawContent);
1820
+ const summary = cleaned || "(model emitted fake tool-call markup instead of a prose summary \u2014 try /retry with a narrower question, or /think to inspect R1's reasoning)";
1821
+ const reasonPrefix = reasonPrefixFor(opts.reason, ctx.maxToolIters);
1822
+ const annotated = `${reasonPrefix}
1823
+
1824
+ ${summary}`;
1825
+ const summaryStats = ctx.recordStats(summaryModel, resp.usage ?? new Usage());
1826
+ ctx.appendAndPersist(buildAssistantMessage(summary, [], summaryModel, resp.reasoningContent));
1827
+ yield {
1828
+ turn: ctx.turn,
1829
+ role: "assistant_final",
1830
+ content: annotated,
1831
+ stats: summaryStats,
1832
+ forcedSummary: true
1833
+ };
1834
+ yield { turn: ctx.turn, role: "done", content: summary };
1835
+ } catch (err) {
1836
+ const label = errorLabelFor(opts.reason, ctx.maxToolIters);
1837
+ yield {
1838
+ turn: ctx.turn,
1839
+ role: "error",
1840
+ content: "",
1841
+ error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
1842
+ };
1843
+ yield { turn: ctx.turn, role: "done", content: "" };
1780
1844
  }
1781
- return args;
1782
1845
  }
1783
- function* iterateJsonObjects(text) {
1846
+
1847
+ // src/loop/shrink.ts
1848
+ function looksLikeCompleteJson(s) {
1849
+ if (!s || !s.trim()) return false;
1850
+ try {
1851
+ JSON.parse(s);
1852
+ return true;
1853
+ } catch {
1854
+ return false;
1855
+ }
1856
+ }
1857
+ function shrinkOversizedToolResults(messages, maxChars) {
1858
+ let healedCount = 0;
1859
+ let healedFrom = 0;
1860
+ const out = messages.map((msg) => {
1861
+ if (msg.role !== "tool") return msg;
1862
+ const content = typeof msg.content === "string" ? msg.content : "";
1863
+ if (content.length <= maxChars) return msg;
1864
+ healedCount += 1;
1865
+ healedFrom += content.length;
1866
+ return { ...msg, content: truncateForModel(content, maxChars) };
1867
+ });
1868
+ return { messages: out, healedCount, healedFrom };
1869
+ }
1870
+ function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
1871
+ let healedCount = 0;
1872
+ let tokensSaved = 0;
1873
+ let charsSaved = 0;
1874
+ const out = messages.map((msg) => {
1875
+ if (msg.role !== "tool") return msg;
1876
+ const content = typeof msg.content === "string" ? msg.content : "";
1877
+ if (content.length <= maxTokens) return msg;
1878
+ const beforeTokens = countTokens(content);
1879
+ if (beforeTokens <= maxTokens) return msg;
1880
+ const truncated = truncateForModelByTokens(content, maxTokens);
1881
+ const afterTokens = countTokens(truncated);
1882
+ healedCount += 1;
1883
+ tokensSaved += Math.max(0, beforeTokens - afterTokens);
1884
+ charsSaved += Math.max(0, content.length - truncated.length);
1885
+ return { ...msg, content: truncated };
1886
+ });
1887
+ return { messages: out, healedCount, tokensSaved, charsSaved };
1888
+ }
1889
+
1890
+ // src/loop/healing.ts
1891
+ function fixToolCallPairing(messages) {
1892
+ const out = [];
1893
+ let droppedAssistantCalls = 0;
1894
+ let droppedStrayTools = 0;
1895
+ for (let i = 0; i < messages.length; i++) {
1896
+ const msg = messages[i];
1897
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
1898
+ const needed = /* @__PURE__ */ new Set();
1899
+ for (const call of msg.tool_calls) {
1900
+ if (call?.id) needed.add(call.id);
1901
+ }
1902
+ const candidates = [];
1903
+ let j = i + 1;
1904
+ while (j < messages.length && needed.size > 0) {
1905
+ const nxt = messages[j];
1906
+ if (nxt.role !== "tool") break;
1907
+ const id = nxt.tool_call_id ?? "";
1908
+ if (!needed.has(id)) break;
1909
+ needed.delete(id);
1910
+ candidates.push(nxt);
1911
+ j++;
1912
+ }
1913
+ if (needed.size === 0) {
1914
+ out.push(msg);
1915
+ for (const r of candidates) out.push(r);
1916
+ i = j - 1;
1917
+ } else {
1918
+ droppedAssistantCalls += 1;
1919
+ droppedStrayTools += candidates.length;
1920
+ i = j - 1;
1921
+ }
1922
+ continue;
1923
+ }
1924
+ if (msg.role === "tool") {
1925
+ droppedStrayTools += 1;
1926
+ continue;
1927
+ }
1928
+ out.push(msg);
1929
+ }
1930
+ return { messages: out, droppedAssistantCalls, droppedStrayTools };
1931
+ }
1932
+ function healLoadedMessages(messages, maxChars) {
1933
+ const shrunk = shrinkOversizedToolResults(messages, maxChars);
1934
+ const paired = fixToolCallPairing(shrunk.messages);
1935
+ const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
1936
+ return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
1937
+ }
1938
+ function stampMissingReasoningForThinkingMode(messages, model) {
1939
+ if (!isThinkingModeModel(model)) {
1940
+ return { messages, stampedCount: 0 };
1941
+ }
1942
+ let stampedCount = 0;
1943
+ const out = messages.map((msg) => {
1944
+ if (msg.role !== "assistant") return msg;
1945
+ if (Object.hasOwn(msg, "reasoning_content")) return msg;
1946
+ stampedCount += 1;
1947
+ return { ...msg, reasoning_content: "" };
1948
+ });
1949
+ return { messages: out, stampedCount };
1950
+ }
1951
+ function healLoadedMessagesByTokens(messages, maxTokens) {
1952
+ const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
1953
+ const paired = fixToolCallPairing(shrunk.messages);
1954
+ const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
1955
+ return {
1956
+ messages: paired.messages,
1957
+ healedCount,
1958
+ tokensSaved: shrunk.tokensSaved,
1959
+ charsSaved: shrunk.charsSaved
1960
+ };
1961
+ }
1962
+
1963
+ // src/loop/hook-events.ts
1964
+ function safeParseToolArgs(raw) {
1965
+ try {
1966
+ return JSON.parse(raw);
1967
+ } catch {
1968
+ return raw;
1969
+ }
1970
+ }
1971
+ function* hookWarnings(outcomes, turn) {
1972
+ for (const o of outcomes) {
1973
+ if (o.decision === "pass") continue;
1974
+ yield { turn, role: "warning", content: formatHookOutcomeMessage(o) };
1975
+ }
1976
+ }
1977
+
1978
+ // src/loop/turn-failure-tracker.ts
1979
+ var FAILURE_ESCALATION_THRESHOLD = 3;
1980
+ var TurnFailureTracker = class {
1981
+ count = 0;
1982
+ types = {};
1983
+ reset() {
1984
+ this.count = 0;
1985
+ this.types = {};
1986
+ }
1987
+ /** True ONLY on the call where the count crosses FAILURE_ESCALATION_THRESHOLD. */
1988
+ noteAndCrossedThreshold(resultJson, repair) {
1989
+ const before = this.count;
1990
+ const bump = (kind, by = 1) => {
1991
+ this.count += by;
1992
+ this.types[kind] = (this.types[kind] ?? 0) + by;
1993
+ };
1994
+ if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
1995
+ bump("search-mismatch");
1996
+ }
1997
+ if (repair) {
1998
+ if (repair.scavenged > 0) bump("scavenged", repair.scavenged);
1999
+ if (repair.truncationsFixed > 0) bump("truncated", repair.truncationsFixed);
2000
+ if (repair.stormsBroken > 0) bump("repeat-loop", repair.stormsBroken);
2001
+ }
2002
+ return before < FAILURE_ESCALATION_THRESHOLD && this.count >= FAILURE_ESCALATION_THRESHOLD;
2003
+ }
2004
+ formatBreakdown() {
2005
+ const parts = Object.entries(this.types).filter(([, n]) => n > 0).map(([kind, n]) => `${n}\xD7 ${kind}`);
2006
+ return parts.length > 0 ? parts.join(", ") : `${this.count} repair/error signal(s)`;
2007
+ }
2008
+ };
2009
+
2010
+ // src/memory/runtime.ts
2011
+ import { createHash } from "crypto";
2012
+ var ImmutablePrefix = class {
2013
+ system;
2014
+ /** Each `addTool` costs one cache-miss turn — DeepSeek's prefix cache is keyed by full tool list. */
2015
+ _toolSpecs;
2016
+ fewShots;
2017
+ /** Invalidated only via `addTool`; bypassing it leaves cache stale → fingerprint diverges from sent prefix. */
2018
+ _fingerprintCache = null;
2019
+ constructor(opts) {
2020
+ this.system = opts.system;
2021
+ this._toolSpecs = [...opts.toolSpecs ?? []];
2022
+ this.fewShots = Object.freeze([...opts.fewShots ?? []]);
2023
+ }
2024
+ get toolSpecs() {
2025
+ return this._toolSpecs;
2026
+ }
2027
+ toMessages() {
2028
+ return [{ role: "system", content: this.system }, ...this.fewShots.map((m) => ({ ...m }))];
2029
+ }
2030
+ tools() {
2031
+ return this._toolSpecs.map((t) => structuredClone(t));
2032
+ }
2033
+ addTool(spec) {
2034
+ const name = spec.function?.name;
2035
+ if (!name) return false;
2036
+ if (this._toolSpecs.some((t) => t.function?.name === name)) return false;
2037
+ this._toolSpecs.push(spec);
2038
+ this._fingerprintCache = null;
2039
+ return true;
2040
+ }
2041
+ /** Mirror of addTool for MCP hot-unbridge. Same cache-miss cost — prefix changes shape. */
2042
+ removeTool(name) {
2043
+ const idx = this._toolSpecs.findIndex((t) => t.function?.name === name);
2044
+ if (idx < 0) return false;
2045
+ this._toolSpecs.splice(idx, 1);
2046
+ this._fingerprintCache = null;
2047
+ return true;
2048
+ }
2049
+ get fingerprint() {
2050
+ if (this._fingerprintCache !== null) return this._fingerprintCache;
2051
+ this._fingerprintCache = this.computeFingerprint();
2052
+ return this._fingerprintCache;
2053
+ }
2054
+ /** Dev/test only — throws on cache drift, which always means a non-`addTool` mutation slipped in. */
2055
+ verifyFingerprint() {
2056
+ const fresh = this.computeFingerprint();
2057
+ if (this._fingerprintCache !== null && this._fingerprintCache !== fresh) {
2058
+ throw new Error(
2059
+ `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.`
2060
+ );
2061
+ }
2062
+ this._fingerprintCache = fresh;
2063
+ return fresh;
2064
+ }
2065
+ computeFingerprint() {
2066
+ const blob = JSON.stringify({
2067
+ system: this.system,
2068
+ tools: this._toolSpecs,
2069
+ shots: this.fewShots
2070
+ });
2071
+ return createHash("sha256").update(blob).digest("hex").slice(0, 16);
2072
+ }
2073
+ };
2074
+ var AppendOnlyLog = class {
2075
+ _entries = [];
2076
+ append(message) {
2077
+ if (!message || typeof message !== "object" || !("role" in message)) {
2078
+ throw new Error(`invalid log entry: ${JSON.stringify(message)}`);
2079
+ }
2080
+ this._entries.push(message);
2081
+ }
2082
+ extend(messages) {
2083
+ for (const m of messages) this.append(m);
2084
+ }
2085
+ /** The one append-only-breaking path — reserved for `/compact` + recovery. Use `append()` otherwise. */
2086
+ compactInPlace(replacement) {
2087
+ this._entries = [...replacement];
2088
+ }
2089
+ get entries() {
2090
+ return this._entries;
2091
+ }
2092
+ toMessages() {
2093
+ return this._entries.map((e) => ({ ...e }));
2094
+ }
2095
+ get length() {
2096
+ return this._entries.length;
2097
+ }
2098
+ };
2099
+ var VolatileScratch = class {
2100
+ reasoning = null;
2101
+ planState = null;
2102
+ notes = [];
2103
+ reset() {
2104
+ this.reasoning = null;
2105
+ this.planState = null;
2106
+ this.notes = [];
2107
+ }
2108
+ };
2109
+
2110
+ // src/repair/scavenge.ts
2111
+ var MAX_SCAVENGE_INPUT = 100 * 1024;
2112
+ function scavengeToolCalls(reasoningContent, opts) {
2113
+ if (!reasoningContent) return { calls: [], notes: [] };
2114
+ if (reasoningContent.length > MAX_SCAVENGE_INPUT) {
2115
+ return {
2116
+ calls: [],
2117
+ notes: [`scavenge skipped: reasoning_content too large (${reasoningContent.length} chars)`]
2118
+ };
2119
+ }
2120
+ const max = opts.maxCalls ?? 4;
2121
+ const notes = [];
2122
+ const out = [];
2123
+ for (const invoke of iterateDsmlInvokes(reasoningContent)) {
2124
+ if (out.length >= max) break;
2125
+ if (!opts.allowedNames.has(invoke.name)) continue;
2126
+ out.push({
2127
+ function: {
2128
+ name: invoke.name,
2129
+ arguments: JSON.stringify(invoke.args)
2130
+ }
2131
+ });
2132
+ notes.push(`scavenged DSML call: ${invoke.name}`);
2133
+ }
2134
+ const nonDsml = stripDsmlBlocks(reasoningContent);
2135
+ for (const candidate of iterateJsonObjects(nonDsml)) {
2136
+ if (out.length >= max) break;
2137
+ const call = coerceToToolCall(candidate, opts.allowedNames);
2138
+ if (call) {
2139
+ out.push(call);
2140
+ notes.push(`scavenged call: ${call.function.name}`);
2141
+ }
2142
+ }
2143
+ return { calls: out, notes };
2144
+ }
2145
+ function stripDsmlBlocks(text) {
2146
+ let out = text;
2147
+ out = out.replace(/<[||]DSML[||]function_calls>[\s\S]*?<\/?[||]DSML[||]function_calls>/g, "");
2148
+ out = out.replace(/<[||]DSML[||]invoke\s+[^>]*>[\s\S]*?<\/[||]DSML[||]invoke>/g, "");
2149
+ return out;
2150
+ }
2151
+ function* iterateDsmlInvokes(text) {
2152
+ const INVOKE_RE = /<[||]DSML[||]invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]DSML[||]invoke>/g;
2153
+ for (const match of text.matchAll(INVOKE_RE)) {
2154
+ const name = match[1];
2155
+ const body = match[2];
2156
+ if (!name || body === void 0) continue;
2157
+ yield { name, args: parseDsmlParameters(body) };
2158
+ }
2159
+ }
2160
+ function parseDsmlParameters(body) {
2161
+ const PARAM_RE = /<[||]DSML[||]parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/[||]DSML[||]parameter>/g;
2162
+ const args = {};
2163
+ for (const m of body.matchAll(PARAM_RE)) {
2164
+ const key = m[1];
2165
+ const stringFlag = m[2];
2166
+ const raw = (m[3] ?? "").trim();
2167
+ if (!key) continue;
2168
+ if (stringFlag === "false") {
2169
+ try {
2170
+ args[key] = JSON.parse(raw);
2171
+ continue;
2172
+ } catch {
2173
+ }
2174
+ }
2175
+ args[key] = raw;
2176
+ }
2177
+ return args;
2178
+ }
2179
+ function* iterateJsonObjects(text) {
1784
2180
  for (let i = 0; i < text.length; i++) {
1785
2181
  if (text[i] !== "{") continue;
1786
2182
  let depth = 0;
@@ -2020,11 +2416,7 @@ function signature(call) {
2020
2416
  }
2021
2417
 
2022
2418
  // src/loop.ts
2023
- var FAILURE_ESCALATION_THRESHOLD = 3;
2024
2419
  var ESCALATION_MODEL = "deepseek-v4-pro";
2025
- var NEEDS_PRO_MARKER_PREFIX = "<<<NEEDS_PRO";
2026
- var NEEDS_PRO_MARKER_RE = /^<<<NEEDS_PRO(?::\s*([^>]*))?>>>/;
2027
- var NEEDS_PRO_BUFFER_CHARS = 256;
2028
2420
  var CacheFirstLoop = class {
2029
2421
  client;
2030
2422
  prefix;
@@ -2060,11 +2452,13 @@ var CacheFirstLoop = class {
2060
2452
  _turnAbort = new AbortController();
2061
2453
  _proArmedForNextTurn = false;
2062
2454
  _escalateThisTurn = false;
2063
- _turnFailureCount = 0;
2064
- _turnFailureTypes = {};
2455
+ _turnFailures = new TurnFailureTracker();
2065
2456
  _turnSelfCorrected = false;
2066
2457
  _foldedThisTurn = false;
2067
2458
  context;
2459
+ get currentTurn() {
2460
+ return this._turn;
2461
+ }
2068
2462
  constructor(opts) {
2069
2463
  this.client = opts.client;
2070
2464
  this.prefix = opts.prefix;
@@ -2233,60 +2627,18 @@ var CacheFirstLoop = class {
2233
2627
  return this._proArmedForNextTurn;
2234
2628
  }
2235
2629
  /** UI surface — true while the current turn is running on pro (armed or auto-escalated). */
2236
- get escalatedThisTurn() {
2237
- return this._escalateThisTurn;
2238
- }
2239
- modelForCurrentCall() {
2240
- return this._escalateThisTurn ? ESCALATION_MODEL : this.model;
2241
- }
2242
- /** Anchored to lead — mid-text matches are normal content (user asking about the marker). */
2243
- parseEscalationMarker(content) {
2244
- const m = NEEDS_PRO_MARKER_RE.exec(content.trimStart());
2245
- if (!m) return { matched: false };
2246
- const reason = m[1]?.trim();
2247
- return { matched: true, reason: reason || void 0 };
2248
- }
2249
- /** Convenience boolean — same gate the streaming path used to call. */
2250
- isEscalationRequest(content) {
2251
- return this.parseEscalationMarker(content).matched;
2252
- }
2253
- /** Drives streaming flush — while plausibly partial, keep accumulating; else flush. */
2254
- looksLikePartialEscalationMarker(buf) {
2255
- const t = buf.trimStart();
2256
- if (t.length === 0) return true;
2257
- if (t.length <= NEEDS_PRO_MARKER_PREFIX.length) {
2258
- return NEEDS_PRO_MARKER_PREFIX.startsWith(t);
2259
- }
2260
- if (!t.startsWith(NEEDS_PRO_MARKER_PREFIX)) return false;
2261
- const rest = t.slice(NEEDS_PRO_MARKER_PREFIX.length);
2262
- if (rest[0] !== ">" && rest[0] !== ":") return false;
2263
- return true;
2264
- }
2265
- /** Returns true ONLY on the tipping call — caller surfaces a one-shot warning. */
2266
- noteToolFailureSignal(resultJson, repair) {
2267
- let bumped = false;
2268
- const bump = (kind, by = 1) => {
2269
- this._turnFailureCount += by;
2270
- this._turnFailureTypes[kind] = (this._turnFailureTypes[kind] ?? 0) + by;
2271
- bumped = true;
2272
- };
2273
- if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
2274
- bump("search-mismatch");
2275
- }
2276
- if (repair) {
2277
- if (repair.scavenged > 0) bump("scavenged", repair.scavenged);
2278
- if (repair.truncationsFixed > 0) bump("truncated", repair.truncationsFixed);
2279
- if (repair.stormsBroken > 0) bump("repeat-loop", repair.stormsBroken);
2280
- }
2281
- if (bumped && !this._escalateThisTurn && this.autoEscalate && this._turnFailureCount >= FAILURE_ESCALATION_THRESHOLD) {
2282
- this._escalateThisTurn = true;
2283
- return true;
2284
- }
2285
- return false;
2630
+ get escalatedThisTurn() {
2631
+ return this._escalateThisTurn;
2632
+ }
2633
+ modelForCurrentCall() {
2634
+ return this._escalateThisTurn ? ESCALATION_MODEL : this.model;
2286
2635
  }
2287
- formatFailureBreakdown() {
2288
- const parts = Object.entries(this._turnFailureTypes).filter(([, n]) => n > 0).map(([kind, n]) => `${n}\xD7 ${kind}`);
2289
- return parts.length > 0 ? parts.join(", ") : `${this._turnFailureCount} repair/error signal(s)`;
2636
+ /** Returns true ONLY on the tipping call — caller surfaces a one-shot warning. */
2637
+ noteToolFailureSignal(resultJson, repair) {
2638
+ if (!this._turnFailures.noteAndCrossedThreshold(resultJson, repair)) return false;
2639
+ if (this._escalateThisTurn || !this.autoEscalate) return false;
2640
+ this._escalateThisTurn = true;
2641
+ return true;
2290
2642
  }
2291
2643
  buildMessages(pendingUser) {
2292
2644
  const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
@@ -2344,8 +2696,7 @@ var CacheFirstLoop = class {
2344
2696
  this._turn++;
2345
2697
  this.scratch.reset();
2346
2698
  this.repair.resetStorm();
2347
- this._turnFailureCount = 0;
2348
- this._turnFailureTypes = {};
2699
+ this._turnFailures.reset();
2349
2700
  this._turnSelfCorrected = false;
2350
2701
  this._escalateThisTurn = false;
2351
2702
  this._foldedThisTurn = false;
@@ -2378,7 +2729,7 @@ var CacheFirstLoop = class {
2378
2729
  content: `aborted at iter ${iter}/${this.maxToolIters} \u2014 stopped without producing a summary (press \u2191 + Enter or /retry to resume)`
2379
2730
  };
2380
2731
  const stoppedMsg = "[aborted by user (Esc) \u2014 no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
2381
- this.appendAndPersist(this.syntheticAssistantMessage(stoppedMsg));
2732
+ this.appendAndPersist(buildSyntheticAssistantMessage(stoppedMsg, this.model));
2382
2733
  yield {
2383
2734
  turn: this._turn,
2384
2735
  role: "assistant_final",
@@ -2540,10 +2891,10 @@ var CacheFirstLoop = class {
2540
2891
  assistantContent += chunk.contentDelta;
2541
2892
  if (bufferForEscalation && !escalationBufFlushed) {
2542
2893
  escalationBuf += chunk.contentDelta;
2543
- if (this.isEscalationRequest(escalationBuf)) {
2894
+ if (isEscalationRequest(escalationBuf)) {
2544
2895
  break;
2545
2896
  }
2546
- if (escalationBuf.length >= NEEDS_PRO_BUFFER_CHARS || !this.looksLikePartialEscalationMarker(escalationBuf)) {
2897
+ if (escalationBuf.length >= NEEDS_PRO_BUFFER_CHARS || !looksLikePartialEscalationMarker(escalationBuf)) {
2547
2898
  escalationBufFlushed = true;
2548
2899
  yield {
2549
2900
  turn: this._turn,
@@ -2600,7 +2951,7 @@ var CacheFirstLoop = class {
2600
2951
  }
2601
2952
  toolCalls = [...callBuf.values()];
2602
2953
  if (bufferForEscalation && !escalationBufFlushed && escalationBuf.length > 0) {
2603
- if (!this.isEscalationRequest(escalationBuf)) {
2954
+ if (!isEscalationRequest(escalationBuf)) {
2604
2955
  yield {
2605
2956
  turn: this._turn,
2606
2957
  role: "assistant_delta",
@@ -2637,8 +2988,8 @@ var CacheFirstLoop = class {
2637
2988
  };
2638
2989
  return;
2639
2990
  }
2640
- if (this.autoEscalate && this.modelForCurrentCall() !== ESCALATION_MODEL && this.isEscalationRequest(assistantContent)) {
2641
- const { reason } = this.parseEscalationMarker(assistantContent);
2991
+ if (this.autoEscalate && this.modelForCurrentCall() !== ESCALATION_MODEL && isEscalationRequest(assistantContent)) {
2992
+ const { reason } = parseEscalationMarker(assistantContent);
2642
2993
  this._escalateThisTurn = true;
2643
2994
  const reasonSuffix = reason ? ` \u2014 ${reason}` : "";
2644
2995
  yield {
@@ -2679,7 +3030,7 @@ var CacheFirstLoop = class {
2679
3030
  assistantContent || null
2680
3031
  );
2681
3032
  this.appendAndPersist(
2682
- this.assistantMessage(
3033
+ buildAssistantMessage(
2683
3034
  assistantContent,
2684
3035
  repairedCalls,
2685
3036
  this.modelForCurrentCall(),
@@ -2699,14 +3050,14 @@ var CacheFirstLoop = class {
2699
3050
  yield {
2700
3051
  turn: this._turn,
2701
3052
  role: "warning",
2702
- content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this.formatFailureBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
3053
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
2703
3054
  };
2704
3055
  }
2705
3056
  const allSuppressed = report.stormsBroken > 0 && repairedCalls.length === 0 && toolCalls.length > 0;
2706
3057
  if (allSuppressed && !this._turnSelfCorrected) {
2707
3058
  this._turnSelfCorrected = true;
2708
3059
  this.replaceTailAssistantMessage(
2709
- this.assistantMessage(
3060
+ buildAssistantMessage(
2710
3061
  assistantContent,
2711
3062
  toolCalls,
2712
3063
  this.modelForCurrentCall(),
@@ -2739,7 +3090,7 @@ var CacheFirstLoop = class {
2739
3090
  }
2740
3091
  if (repairedCalls.length === 0) {
2741
3092
  if (allSuppressed) {
2742
- yield* this.forceSummaryAfterIterLimit({ reason: "stuck" });
3093
+ yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "stuck" });
2743
3094
  return;
2744
3095
  }
2745
3096
  yield { turn: this._turn, role: "done", content: assistantContent };
@@ -2758,384 +3109,122 @@ var CacheFirstLoop = class {
2758
3109
  };
2759
3110
  const result = await this.compactHistory({ keepRecentTokens: decision.tailBudget });
2760
3111
  if (result.folded) {
2761
- yield {
2762
- turn: this._turn,
2763
- role: "warning",
2764
- content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
2765
- before / ctxMax * 100
2766
- )}%) \u2014 ${decision.aggressive ? "aggressively folded" : "folded"} ${result.beforeMessages} messages \u2192 ${result.afterMessages} (summary ${result.summaryChars} chars). Continuing.`
2767
- };
2768
- }
2769
- } else if (decision.kind === "exit-with-summary") {
2770
- const before = decision.promptTokens;
2771
- const ctxMax = decision.ctxMax;
2772
- yield {
2773
- turn: this._turn,
2774
- role: "warning",
2775
- content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
2776
- before / ctxMax * 100
2777
- )}%) \u2014 forcing summary from what was gathered. Run /compact, /clear, or /new to reset.`
2778
- };
2779
- this.context.trimTrailingToolCalls();
2780
- yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
2781
- return;
2782
- }
2783
- for (const call of repairedCalls) {
2784
- const name = call.function?.name ?? "";
2785
- const args = call.function?.arguments ?? "{}";
2786
- yield {
2787
- turn: this._turn,
2788
- role: "tool_start",
2789
- content: "",
2790
- toolName: name,
2791
- toolArgs: args
2792
- };
2793
- const parsedArgs = safeParseToolArgs(args);
2794
- const preReport = await runHooks({
2795
- hooks: this.hooks,
2796
- payload: {
2797
- event: "PreToolUse",
2798
- cwd: this.hookCwd,
2799
- toolName: name,
2800
- toolArgs: parsedArgs
2801
- }
2802
- });
2803
- for (const w of hookWarnings(preReport.outcomes, this._turn)) yield w;
2804
- let result;
2805
- if (preReport.blocked) {
2806
- const blocking = preReport.outcomes[preReport.outcomes.length - 1];
2807
- const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
2808
- result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}
2809
- ${reason}`;
2810
- } else {
2811
- result = await this.tools.dispatch(name, args, {
2812
- signal,
2813
- maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
2814
- confirmationGate: this.confirmationGate
2815
- });
2816
- const postReport = await runHooks({
2817
- hooks: this.hooks,
2818
- payload: {
2819
- event: "PostToolUse",
2820
- cwd: this.hookCwd,
2821
- toolName: name,
2822
- toolArgs: parsedArgs,
2823
- toolResult: result
2824
- }
2825
- });
2826
- for (const w of hookWarnings(postReport.outcomes, this._turn)) yield w;
2827
- }
2828
- this.appendAndPersist({
2829
- role: "tool",
2830
- tool_call_id: call.id ?? "",
2831
- name,
2832
- content: result
2833
- });
2834
- if (this.noteToolFailureSignal(result)) {
2835
- yield {
2836
- turn: this._turn,
2837
- role: "warning",
2838
- content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this.formatFailureBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
2839
- };
2840
- }
2841
- yield {
2842
- turn: this._turn,
2843
- role: "tool",
2844
- content: result,
2845
- toolName: name,
2846
- toolArgs: args
2847
- };
2848
- }
2849
- }
2850
- yield* this.forceSummaryAfterIterLimit({ reason: "budget" });
2851
- }
2852
- async *forceSummaryAfterIterLimit(opts = { reason: "budget" }) {
2853
- try {
2854
- yield {
2855
- turn: this._turn,
2856
- role: "status",
2857
- content: "summarizing what was gathered\u2026"
2858
- };
2859
- const messages = this.buildMessages(null);
2860
- messages.push({
2861
- role: "user",
2862
- content: "I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks \u2014 they will be silently discarded. Just plain text."
2863
- });
2864
- const summaryModel = "deepseek-v4-flash";
2865
- const summaryEffort = "high";
2866
- const resp = await this.client.chat({
2867
- model: summaryModel,
2868
- messages,
2869
- // no tools → model is forced to answer in text
2870
- signal: this._turnAbort.signal,
2871
- thinking: thinkingModeForModel(summaryModel),
2872
- reasoningEffort: summaryEffort
2873
- });
2874
- const rawContent = resp.content?.trim() ?? "";
2875
- const cleaned = stripHallucinatedToolMarkup(rawContent);
2876
- const summary = cleaned || "(model emitted fake tool-call markup instead of a prose summary \u2014 try /retry with a narrower question, or /think to inspect R1's reasoning)";
2877
- const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
2878
- const annotated = `${reasonPrefix}
2879
-
2880
- ${summary}`;
2881
- const summaryStats = this.stats.record(this._turn, summaryModel, resp.usage ?? new Usage());
2882
- this.appendAndPersist(
2883
- this.assistantMessage(summary, [], summaryModel, resp.reasoningContent)
2884
- );
2885
- yield {
2886
- turn: this._turn,
2887
- role: "assistant_final",
2888
- content: annotated,
2889
- stats: summaryStats,
2890
- forcedSummary: true
2891
- };
2892
- yield { turn: this._turn, role: "done", content: summary };
2893
- } catch (err) {
2894
- const label = errorLabelFor(opts.reason, this.maxToolIters);
2895
- yield {
2896
- turn: this._turn,
2897
- role: "error",
2898
- content: "",
2899
- error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
2900
- };
2901
- yield { turn: this._turn, role: "done", content: "" };
2902
- }
2903
- }
2904
- async run(userInput, onEvent) {
2905
- let final = "";
2906
- for await (const ev of this.step(userInput)) {
2907
- onEvent?.(ev);
2908
- if (ev.role === "assistant_final") final = ev.content;
2909
- if (ev.role === "done") break;
2910
- }
2911
- return final;
2912
- }
2913
- /** Thinking-mode producer ⇒ reasoning_content MUST be set (even ""), or next call 400s. */
2914
- assistantMessage(content, toolCalls, producingModel, reasoningContent) {
2915
- const msg = { role: "assistant", content };
2916
- if (toolCalls.length > 0) msg.tool_calls = toolCalls;
2917
- if (isThinkingModeModel(producingModel) || reasoningContent && reasoningContent.length > 0) {
2918
- msg.reasoning_content = reasoningContent ?? "";
2919
- }
2920
- return msg;
2921
- }
2922
- /** Abort notices etc — uses this.model as stand-in producer for the thinking-mode stamp. */
2923
- syntheticAssistantMessage(content) {
2924
- return this.assistantMessage(content, [], this.model, "");
2925
- }
2926
- };
2927
- function isThinkingModeModel(model) {
2928
- if (model.includes("reasoner")) return true;
2929
- if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
2930
- return false;
2931
- }
2932
- function thinkingModeForModel(model) {
2933
- if (model === "deepseek-chat") return "disabled";
2934
- if (model.includes("reasoner")) return "enabled";
2935
- if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
2936
- return void 0;
2937
- }
2938
- function stripHallucinatedToolMarkup(s) {
2939
- let out = s;
2940
- out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
2941
- out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
2942
- out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
2943
- out = out.replace(/<|DSML|[\s\S]*$/g, "");
2944
- return out.trim();
2945
- }
2946
- function parsePositiveIntEnv(raw) {
2947
- if (!raw) return void 0;
2948
- const n = Number.parseInt(raw, 10);
2949
- return Number.isFinite(n) && n > 0 ? n : void 0;
2950
- }
2951
- function safeParseToolArgs(raw) {
2952
- try {
2953
- return JSON.parse(raw);
2954
- } catch {
2955
- return raw;
2956
- }
2957
- }
2958
- function looksLikeCompleteJson(s) {
2959
- if (!s || !s.trim()) return false;
2960
- try {
2961
- JSON.parse(s);
2962
- return true;
2963
- } catch {
2964
- return false;
2965
- }
2966
- }
2967
- function* hookWarnings(outcomes, turn) {
2968
- for (const o of outcomes) {
2969
- if (o.decision === "pass") continue;
2970
- yield { turn, role: "warning", content: formatHookOutcomeMessage(o) };
2971
- }
2972
- }
2973
- function reasonPrefixFor(reason, iterCap) {
2974
- if (reason === "aborted") return "[aborted by user (Esc) \u2014 summarizing what I found so far]";
2975
- if (reason === "context-guard") {
2976
- return "[context budget running low \u2014 summarizing before the next call would overflow]";
2977
- }
2978
- if (reason === "stuck") {
2979
- return "[stuck on a repeated tool call \u2014 explaining what was tried and what's blocking progress]";
2980
- }
2981
- return `[tool-call budget (${iterCap}) reached \u2014 forcing summary from what I found]`;
2982
- }
2983
- function errorLabelFor(reason, iterCap) {
2984
- if (reason === "aborted") return "aborted by user";
2985
- if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
2986
- if (reason === "stuck") return "stuck (repeated tool call suppressed by storm-breaker)";
2987
- return `tool-call budget (${iterCap}) reached`;
2988
- }
2989
- function summarizeBranch(chosen, samples) {
2990
- return {
2991
- budget: samples.length,
2992
- chosenIndex: chosen.index,
2993
- uncertainties: samples.map((s) => s.planState.uncertainties.length),
2994
- temperatures: samples.map((s) => s.temperature)
2995
- };
2996
- }
2997
- function shrinkOversizedToolResults(messages, maxChars) {
2998
- let healedCount = 0;
2999
- let healedFrom = 0;
3000
- const out = messages.map((msg) => {
3001
- if (msg.role !== "tool") return msg;
3002
- const content = typeof msg.content === "string" ? msg.content : "";
3003
- if (content.length <= maxChars) return msg;
3004
- healedCount += 1;
3005
- healedFrom += content.length;
3006
- return { ...msg, content: truncateForModel(content, maxChars) };
3007
- });
3008
- return { messages: out, healedCount, healedFrom };
3009
- }
3010
- function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
3011
- let healedCount = 0;
3012
- let tokensSaved = 0;
3013
- let charsSaved = 0;
3014
- const out = messages.map((msg) => {
3015
- if (msg.role !== "tool") return msg;
3016
- const content = typeof msg.content === "string" ? msg.content : "";
3017
- if (content.length <= maxTokens) return msg;
3018
- const beforeTokens = countTokens(content);
3019
- if (beforeTokens <= maxTokens) return msg;
3020
- const truncated = truncateForModelByTokens(content, maxTokens);
3021
- const afterTokens = countTokens(truncated);
3022
- healedCount += 1;
3023
- tokensSaved += Math.max(0, beforeTokens - afterTokens);
3024
- charsSaved += Math.max(0, content.length - truncated.length);
3025
- return { ...msg, content: truncated };
3026
- });
3027
- return { messages: out, healedCount, tokensSaved, charsSaved };
3028
- }
3029
- function fixToolCallPairing(messages) {
3030
- const out = [];
3031
- let droppedAssistantCalls = 0;
3032
- let droppedStrayTools = 0;
3033
- for (let i = 0; i < messages.length; i++) {
3034
- const msg = messages[i];
3035
- if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
3036
- const needed = /* @__PURE__ */ new Set();
3037
- for (const call of msg.tool_calls) {
3038
- if (call?.id) needed.add(call.id);
3039
- }
3040
- const candidates = [];
3041
- let j = i + 1;
3042
- while (j < messages.length && needed.size > 0) {
3043
- const nxt = messages[j];
3044
- if (nxt.role !== "tool") break;
3045
- const id = nxt.tool_call_id ?? "";
3046
- if (!needed.has(id)) break;
3047
- needed.delete(id);
3048
- candidates.push(nxt);
3049
- j++;
3112
+ yield {
3113
+ turn: this._turn,
3114
+ role: "warning",
3115
+ content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
3116
+ before / ctxMax * 100
3117
+ )}%) \u2014 ${decision.aggressive ? "aggressively folded" : "folded"} ${result.beforeMessages} messages \u2192 ${result.afterMessages} (summary ${result.summaryChars} chars). Continuing.`
3118
+ };
3119
+ }
3120
+ } else if (decision.kind === "exit-with-summary") {
3121
+ const before = decision.promptTokens;
3122
+ const ctxMax = decision.ctxMax;
3123
+ yield {
3124
+ turn: this._turn,
3125
+ role: "warning",
3126
+ content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
3127
+ before / ctxMax * 100
3128
+ )}%) \u2014 forcing summary from what was gathered. Run /compact, /clear, or /new to reset.`
3129
+ };
3130
+ this.context.trimTrailingToolCalls();
3131
+ yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
3132
+ return;
3050
3133
  }
3051
- if (needed.size === 0) {
3052
- out.push(msg);
3053
- for (const r of candidates) out.push(r);
3054
- i = j - 1;
3055
- } else {
3056
- droppedAssistantCalls += 1;
3057
- droppedStrayTools += candidates.length;
3058
- i = j - 1;
3134
+ for (const call of repairedCalls) {
3135
+ const name = call.function?.name ?? "";
3136
+ const args = call.function?.arguments ?? "{}";
3137
+ yield {
3138
+ turn: this._turn,
3139
+ role: "tool_start",
3140
+ content: "",
3141
+ toolName: name,
3142
+ toolArgs: args
3143
+ };
3144
+ const parsedArgs = safeParseToolArgs(args);
3145
+ const preReport = await runHooks({
3146
+ hooks: this.hooks,
3147
+ payload: {
3148
+ event: "PreToolUse",
3149
+ cwd: this.hookCwd,
3150
+ toolName: name,
3151
+ toolArgs: parsedArgs
3152
+ }
3153
+ });
3154
+ for (const w of hookWarnings(preReport.outcomes, this._turn)) yield w;
3155
+ let result;
3156
+ if (preReport.blocked) {
3157
+ const blocking = preReport.outcomes[preReport.outcomes.length - 1];
3158
+ const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
3159
+ result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}
3160
+ ${reason}`;
3161
+ } else {
3162
+ result = await this.tools.dispatch(name, args, {
3163
+ signal,
3164
+ maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
3165
+ confirmationGate: this.confirmationGate
3166
+ });
3167
+ const postReport = await runHooks({
3168
+ hooks: this.hooks,
3169
+ payload: {
3170
+ event: "PostToolUse",
3171
+ cwd: this.hookCwd,
3172
+ toolName: name,
3173
+ toolArgs: parsedArgs,
3174
+ toolResult: result
3175
+ }
3176
+ });
3177
+ for (const w of hookWarnings(postReport.outcomes, this._turn)) yield w;
3178
+ }
3179
+ this.appendAndPersist({
3180
+ role: "tool",
3181
+ tool_call_id: call.id ?? "",
3182
+ name,
3183
+ content: result
3184
+ });
3185
+ if (this.noteToolFailureSignal(result)) {
3186
+ yield {
3187
+ turn: this._turn,
3188
+ role: "warning",
3189
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
3190
+ };
3191
+ }
3192
+ yield {
3193
+ turn: this._turn,
3194
+ role: "tool",
3195
+ content: result,
3196
+ toolName: name,
3197
+ toolArgs: args
3198
+ };
3059
3199
  }
3060
- continue;
3061
- }
3062
- if (msg.role === "tool") {
3063
- droppedStrayTools += 1;
3064
- continue;
3065
3200
  }
3066
- out.push(msg);
3067
- }
3068
- return { messages: out, droppedAssistantCalls, droppedStrayTools };
3069
- }
3070
- function healLoadedMessages(messages, maxChars) {
3071
- const shrunk = shrinkOversizedToolResults(messages, maxChars);
3072
- const paired = fixToolCallPairing(shrunk.messages);
3073
- const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
3074
- return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
3075
- }
3076
- function stampMissingReasoningForThinkingMode(messages, model) {
3077
- if (!isThinkingModeModel(model)) {
3078
- return { messages, stampedCount: 0 };
3079
- }
3080
- let stampedCount = 0;
3081
- const out = messages.map((msg) => {
3082
- if (msg.role !== "assistant") return msg;
3083
- if (Object.hasOwn(msg, "reasoning_content")) return msg;
3084
- stampedCount += 1;
3085
- return { ...msg, reasoning_content: "" };
3086
- });
3087
- return { messages: out, stampedCount };
3088
- }
3089
- function healLoadedMessagesByTokens(messages, maxTokens) {
3090
- const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
3091
- const paired = fixToolCallPairing(shrunk.messages);
3092
- const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
3093
- return {
3094
- messages: paired.messages,
3095
- healedCount,
3096
- tokensSaved: shrunk.tokensSaved,
3097
- charsSaved: shrunk.charsSaved
3098
- };
3099
- }
3100
- function formatLoopError(err) {
3101
- const msg = err.message ?? "";
3102
- if (msg.includes("maximum context length")) {
3103
- const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
3104
- const requested = reqMatch ? `${Number(reqMatch[1]).toLocaleString()} tokens` : "too many tokens";
3105
- return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load \u2014 a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
3106
- }
3107
- const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
3108
- if (!m) return msg;
3109
- const status = m[1] ?? "";
3110
- const body = m[2] ?? "";
3111
- const inner = extractDeepSeekErrorMessage(body);
3112
- if (status === "401") {
3113
- return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
3114
- }
3115
- if (status === "402") {
3116
- return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up \u2014 the panel header shows your balance once it's non-zero.`;
3201
+ yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "budget" });
3117
3202
  }
3118
- if (status === "422") {
3119
- return `Invalid parameter (DeepSeek 422): ${inner}`;
3120
- }
3121
- if (status === "400") {
3122
- return `Bad request (DeepSeek 400): ${inner}`;
3203
+ summaryContext() {
3204
+ return {
3205
+ client: this.client,
3206
+ signal: this._turnAbort.signal,
3207
+ buildMessages: () => this.buildMessages(null),
3208
+ appendAndPersist: (m) => this.appendAndPersist(m),
3209
+ recordStats: (model, usage) => this.stats.record(this._turn, model, usage),
3210
+ turn: this._turn,
3211
+ maxToolIters: this.maxToolIters
3212
+ };
3123
3213
  }
3124
- return msg;
3125
- }
3126
- function extractDeepSeekErrorMessage(body) {
3127
- const trimmed = body.trim();
3128
- if (!trimmed) return "(no message)";
3129
- try {
3130
- const parsed = JSON.parse(trimmed);
3131
- if (parsed && typeof parsed === "object") {
3132
- const obj = parsed;
3133
- if (obj.error && typeof obj.error.message === "string") return obj.error.message;
3134
- if (typeof obj.message === "string") return obj.message;
3214
+ async run(userInput, onEvent) {
3215
+ let final = "";
3216
+ for await (const ev of this.step(userInput)) {
3217
+ onEvent?.(ev);
3218
+ if (ev.role === "assistant_final") final = ev.content;
3219
+ if (ev.role === "done") break;
3135
3220
  }
3136
- } catch {
3221
+ return final;
3137
3222
  }
3138
- return trimmed;
3223
+ };
3224
+ function parsePositiveIntEnv(raw) {
3225
+ if (!raw) return void 0;
3226
+ const n = Number.parseInt(raw, 10);
3227
+ return Number.isFinite(n) && n > 0 ? n : void 0;
3139
3228
  }
3140
3229
 
3141
3230
  // src/at-mentions.ts
@@ -3356,17 +3445,18 @@ function rankPickerCandidates(files, query, limitOrOpts) {
3356
3445
  var AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
3357
3446
  function expandAtMentions(text, rootDir, opts = {}) {
3358
3447
  const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
3359
- const fs2 = opts.fs ?? defaultFs;
3448
+ const fs4 = opts.fs ?? defaultFs;
3360
3449
  const root = resolve(rootDir);
3361
3450
  const seen = /* @__PURE__ */ new Map();
3362
3451
  const expansions = [];
3363
3452
  for (const match of text.matchAll(AT_MENTION_PATTERN)) {
3364
3453
  const rawPath = match[1] ?? "";
3365
- const cleaned = rawPath.replace(/\.+$/, "");
3454
+ let cleaned = rawPath;
3455
+ while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
3366
3456
  if (!cleaned) continue;
3367
3457
  const token = `@${cleaned}`;
3368
3458
  if (seen.has(token)) continue;
3369
- const expansion = resolveMention(cleaned, root, maxBytes, fs2);
3459
+ const expansion = resolveMention(cleaned, root, maxBytes, fs4);
3370
3460
  seen.set(token, expansion);
3371
3461
  expansions.push(expansion);
3372
3462
  }
@@ -3374,7 +3464,7 @@ function expandAtMentions(text, rootDir, opts = {}) {
3374
3464
  const blocks = [];
3375
3465
  for (const ex of expansions) {
3376
3466
  if (ex.ok) {
3377
- const content = readSafe(root, ex.path, fs2);
3467
+ const content = readSafe(root, ex.path, fs4);
3378
3468
  blocks.push(`<file path="${ex.path}">
3379
3469
  ${content}
3380
3470
  </file>`);
@@ -3388,7 +3478,7 @@ ${content}
3388
3478
  ${blocks.join("\n\n")}`;
3389
3479
  return { text: augmented, expansions };
3390
3480
  }
3391
- function resolveMention(rawPath, root, maxBytes, fs2) {
3481
+ function resolveMention(rawPath, root, maxBytes, fs4) {
3392
3482
  if (isAbsolute(rawPath)) {
3393
3483
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
3394
3484
  }
@@ -3397,22 +3487,22 @@ function resolveMention(rawPath, root, maxBytes, fs2) {
3397
3487
  if (rel.startsWith("..") || isAbsolute(rel)) {
3398
3488
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
3399
3489
  }
3400
- if (!fs2.exists(resolved)) {
3490
+ if (!fs4.exists(resolved)) {
3401
3491
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
3402
3492
  }
3403
- if (!fs2.isFile(resolved)) {
3493
+ if (!fs4.isFile(resolved)) {
3404
3494
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
3405
3495
  }
3406
- const size = fs2.size(resolved);
3496
+ const size = fs4.size(resolved);
3407
3497
  if (size > maxBytes) {
3408
3498
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
3409
3499
  }
3410
3500
  return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
3411
3501
  }
3412
- function readSafe(root, rawPath, fs2) {
3502
+ function readSafe(root, rawPath, fs4) {
3413
3503
  const resolved = resolve(root, rawPath);
3414
3504
  try {
3415
- return fs2.read(resolved);
3505
+ return fs4.read(resolved);
3416
3506
  } catch {
3417
3507
  return "(read failed)";
3418
3508
  }
@@ -4161,8 +4251,8 @@ function applyMemoryStack(basePrompt, rootDir) {
4161
4251
  }
4162
4252
 
4163
4253
  // src/tools/filesystem.ts
4164
- import { promises as fs } from "fs";
4165
- import * as pathMod from "path";
4254
+ import { promises as fs3 } from "fs";
4255
+ import * as pathMod3 from "path";
4166
4256
  import picomatch2 from "picomatch";
4167
4257
 
4168
4258
  // src/index/config.ts
@@ -4250,6 +4340,214 @@ var DEFAULT_INDEX_EXCLUDES = {
4250
4340
  };
4251
4341
  var DEFAULT_MAX_FILE_BYTES = 256 * 1024;
4252
4342
 
4343
+ // src/tools/fs/edit.ts
4344
+ import { promises as fs } from "fs";
4345
+ import * as pathMod from "path";
4346
+ function displayRel(rootDir, full) {
4347
+ return pathMod.relative(rootDir, full).replaceAll("\\", "/");
4348
+ }
4349
+ async function applyEdit(rootDir, abs, args) {
4350
+ if (args.search.length === 0) {
4351
+ throw new Error("edit_file: search cannot be empty");
4352
+ }
4353
+ const before = await fs.readFile(abs, "utf8");
4354
+ const le = before.includes("\r\n") ? "\r\n" : "\n";
4355
+ const adaptedSearch = args.search.replace(/\r?\n/g, le);
4356
+ const adaptedReplace = args.replace.replace(/\r?\n/g, le);
4357
+ const firstIdx = before.indexOf(adaptedSearch);
4358
+ if (firstIdx < 0) {
4359
+ throw new Error(`edit_file: search text not found in ${displayRel(rootDir, abs)}`);
4360
+ }
4361
+ const nextIdx = before.indexOf(adaptedSearch, firstIdx + 1);
4362
+ if (nextIdx >= 0) {
4363
+ throw new Error(
4364
+ `edit_file: search text appears multiple times in ${displayRel(rootDir, abs)} \u2014 include more context to disambiguate`
4365
+ );
4366
+ }
4367
+ const after = before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
4368
+ await fs.writeFile(abs, after, "utf8");
4369
+ const rel = displayRel(rootDir, abs);
4370
+ const header = `edited ${rel} (${adaptedSearch.length}\u2192${adaptedReplace.length} chars)`;
4371
+ const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
4372
+ const diff = renderEditDiff(adaptedSearch, adaptedReplace, startLine);
4373
+ return `${header}
4374
+ ${diff}`;
4375
+ }
4376
+ function renderEditDiff(search, replace, startLine) {
4377
+ const a = search.split(/\r?\n/);
4378
+ const b = replace.split(/\r?\n/);
4379
+ const diff = lineDiff(a, b);
4380
+ const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
4381
+ const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
4382
+ return `${hunk}
4383
+ ${body}`;
4384
+ }
4385
+ function lineDiff(a, b) {
4386
+ const n = a.length;
4387
+ const m = b.length;
4388
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
4389
+ for (let i2 = 1; i2 <= n; i2++) {
4390
+ for (let j2 = 1; j2 <= m; j2++) {
4391
+ if (a[i2 - 1] === b[j2 - 1]) dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
4392
+ else dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
4393
+ }
4394
+ }
4395
+ const out = [];
4396
+ let i = n;
4397
+ let j = m;
4398
+ while (i > 0 && j > 0) {
4399
+ if (a[i - 1] === b[j - 1]) {
4400
+ out.unshift({ op: " ", line: a[i - 1] });
4401
+ i--;
4402
+ j--;
4403
+ } else if ((dp[i - 1][j] ?? 0) > (dp[i][j - 1] ?? 0)) {
4404
+ out.unshift({ op: "-", line: a[i - 1] });
4405
+ i--;
4406
+ } else {
4407
+ out.unshift({ op: "+", line: b[j - 1] });
4408
+ j--;
4409
+ }
4410
+ }
4411
+ while (i > 0) {
4412
+ out.unshift({ op: "-", line: a[i - 1] });
4413
+ i--;
4414
+ }
4415
+ while (j > 0) {
4416
+ out.unshift({ op: "+", line: b[j - 1] });
4417
+ j--;
4418
+ }
4419
+ return out;
4420
+ }
4421
+
4422
+ // src/tools/fs/search.ts
4423
+ import { promises as fs2 } from "fs";
4424
+ import * as pathMod2 from "path";
4425
+ function displayRel2(rootDir, full) {
4426
+ return pathMod2.relative(rootDir, full).replaceAll("\\", "/");
4427
+ }
4428
+ async function searchFiles(ctx, startAbs, args) {
4429
+ const needle = args.pattern.toLowerCase();
4430
+ const includeDeps = args.include_deps === true;
4431
+ let re = null;
4432
+ try {
4433
+ re = new RegExp(args.pattern, "i");
4434
+ } catch {
4435
+ re = null;
4436
+ }
4437
+ const matches = [];
4438
+ let totalBytes = 0;
4439
+ const walk2 = async (dir) => {
4440
+ let entries;
4441
+ try {
4442
+ entries = await fs2.readdir(dir, { withFileTypes: true });
4443
+ } catch {
4444
+ return;
4445
+ }
4446
+ for (const e of entries) {
4447
+ const full = pathMod2.join(dir, e.name);
4448
+ const lower = e.name.toLowerCase();
4449
+ const hit = re ? re.test(e.name) : lower.includes(needle);
4450
+ if (hit) {
4451
+ const rel = displayRel2(ctx.rootDir, full);
4452
+ if (totalBytes + rel.length + 1 > ctx.maxListBytes) {
4453
+ matches.push("[\u2026 search truncated \u2014 refine pattern \u2026]");
4454
+ return;
4455
+ }
4456
+ matches.push(rel);
4457
+ totalBytes += rel.length + 1;
4458
+ }
4459
+ if (e.isDirectory()) {
4460
+ if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
4461
+ await walk2(full);
4462
+ }
4463
+ }
4464
+ };
4465
+ await walk2(startAbs);
4466
+ return matches.length === 0 ? "(no matches)" : matches.join("\n");
4467
+ }
4468
+ async function searchContent(ctx, startAbs, args) {
4469
+ const caseSensitive = args.case_sensitive === true;
4470
+ const includeDeps = args.include_deps === true;
4471
+ let re = null;
4472
+ try {
4473
+ re = new RegExp(args.pattern, caseSensitive ? "" : "i");
4474
+ } catch {
4475
+ re = null;
4476
+ }
4477
+ const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
4478
+ const matches = [];
4479
+ let totalBytes = 0;
4480
+ let scanned = 0;
4481
+ let truncated = false;
4482
+ const walk2 = async (dir) => {
4483
+ if (truncated) return;
4484
+ let entries;
4485
+ try {
4486
+ entries = await fs2.readdir(dir, { withFileTypes: true });
4487
+ } catch {
4488
+ return;
4489
+ }
4490
+ for (const e of entries) {
4491
+ if (truncated) return;
4492
+ if (e.isDirectory()) {
4493
+ if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
4494
+ await walk2(pathMod2.join(dir, e.name));
4495
+ continue;
4496
+ }
4497
+ if (!e.isFile()) continue;
4498
+ const full = pathMod2.join(dir, e.name);
4499
+ if (ctx.nameMatch && !ctx.nameMatch(e.name, displayRel2(ctx.rootDir, full))) continue;
4500
+ if (ctx.isBinaryByName(e.name)) continue;
4501
+ let fh;
4502
+ try {
4503
+ fh = await fs2.open(full, "r");
4504
+ } catch {
4505
+ continue;
4506
+ }
4507
+ let raw;
4508
+ try {
4509
+ const st = await fh.stat();
4510
+ if (st.size > 2 * 1024 * 1024) {
4511
+ await fh.close();
4512
+ continue;
4513
+ }
4514
+ raw = await fh.readFile();
4515
+ } catch {
4516
+ await fh.close().catch(() => {
4517
+ });
4518
+ continue;
4519
+ }
4520
+ await fh.close();
4521
+ const firstNul = raw.indexOf(0);
4522
+ if (firstNul !== -1 && firstNul < 8 * 1024) continue;
4523
+ const text = raw.toString("utf8");
4524
+ const rel = displayRel2(ctx.rootDir, full);
4525
+ const lines = text.split(/\r?\n/);
4526
+ for (let li = 0; li < lines.length; li++) {
4527
+ const line = lines[li];
4528
+ const lineForCheck = caseSensitive ? line : line.toLowerCase();
4529
+ const hit = re ? re.test(line) : lineForCheck.includes(needle);
4530
+ if (!hit) continue;
4531
+ const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
4532
+ const out = `${rel}:${li + 1}: ${display}`;
4533
+ if (totalBytes + out.length + 1 > ctx.maxListBytes) {
4534
+ matches.push(`[\u2026 truncated at ${ctx.maxListBytes} bytes \u2014 refine pattern or path \u2026]`);
4535
+ truncated = true;
4536
+ return;
4537
+ }
4538
+ matches.push(out);
4539
+ totalBytes += out.length + 1;
4540
+ }
4541
+ scanned++;
4542
+ }
4543
+ };
4544
+ await walk2(startAbs);
4545
+ if (matches.length === 0) {
4546
+ return scanned === 0 ? "(no files scanned \u2014 path empty or all files filtered out)" : `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
4547
+ }
4548
+ return matches.join("\n");
4549
+ }
4550
+
4253
4551
  // src/tools/filesystem.ts
4254
4552
  var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
4255
4553
  var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
@@ -4258,8 +4556,8 @@ var AUTO_PREVIEW_HEAD_LINES = 80;
4258
4556
  var AUTO_PREVIEW_TAIL_LINES = 40;
4259
4557
  var SKIP_DIR_NAMES = new Set(DEFAULT_INDEX_EXCLUDES.dirs);
4260
4558
  var BINARY_EXTENSIONS = new Set(DEFAULT_INDEX_EXCLUDES.exts);
4261
- function displayRel(rootDir, full) {
4262
- return pathMod.relative(rootDir, full).replaceAll("\\", "/");
4559
+ function displayRel3(rootDir, full) {
4560
+ return pathMod3.relative(rootDir, full).replaceAll("\\", "/");
4263
4561
  }
4264
4562
  var GLOB_METACHARS = /[*?{[]/;
4265
4563
  function compileNameFilter(filter) {
@@ -4278,7 +4576,7 @@ function isLikelyBinaryByName(name) {
4278
4576
  return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
4279
4577
  }
4280
4578
  function registerFilesystemTools(registry, opts) {
4281
- const rootDir = pathMod.resolve(opts.rootDir);
4579
+ const rootDir = pathMod3.resolve(opts.rootDir);
4282
4580
  const allowWriting = opts.allowWriting !== false;
4283
4581
  const maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
4284
4582
  const maxListBytes = opts.maxListBytes ?? DEFAULT_MAX_LIST_BYTES;
@@ -4291,10 +4589,10 @@ function registerFilesystemTools(registry, opts) {
4291
4589
  normalized = normalized.slice(1);
4292
4590
  }
4293
4591
  if (normalized.length === 0) normalized = ".";
4294
- const resolved = pathMod.resolve(rootDir, normalized);
4295
- const normRoot = pathMod.resolve(rootDir);
4296
- const rel = pathMod.relative(normRoot, resolved);
4297
- if (rel.startsWith("..") || pathMod.isAbsolute(rel)) {
4592
+ const resolved = pathMod3.resolve(rootDir, normalized);
4593
+ const normRoot = pathMod3.resolve(rootDir);
4594
+ const rel = pathMod3.relative(normRoot, resolved);
4595
+ if (rel.startsWith("..") || pathMod3.isAbsolute(rel)) {
4298
4596
  throw new Error(`path escapes sandbox root (${normRoot}): ${raw}`);
4299
4597
  }
4300
4598
  return resolved;
@@ -4322,11 +4620,17 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
4322
4620
  },
4323
4621
  fn: async (args) => {
4324
4622
  const abs = safePath(args.path);
4325
- const stat2 = await fs.stat(abs);
4326
- if (stat2.isDirectory()) {
4327
- throw new Error(`not a file: ${args.path} (it's a directory)`);
4623
+ const fh = await fs3.open(abs, "r");
4624
+ let raw;
4625
+ try {
4626
+ const stat2 = await fh.stat();
4627
+ if (stat2.isDirectory()) {
4628
+ throw new Error(`not a file: ${args.path} (it's a directory)`);
4629
+ }
4630
+ raw = await fh.readFile();
4631
+ } finally {
4632
+ await fh.close();
4328
4633
  }
4329
- const raw = await fs.readFile(abs);
4330
4634
  if (raw.length > maxReadBytes) {
4331
4635
  const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
4332
4636
  return `${headBytes}
@@ -4388,7 +4692,7 @@ ${slice.join("\n")}`;
4388
4692
  },
4389
4693
  fn: async (args) => {
4390
4694
  const abs = safePath(args.path ?? ".");
4391
- const entries = await fs.readdir(abs, { withFileTypes: true });
4695
+ const entries = await fs3.readdir(abs, { withFileTypes: true });
4392
4696
  const lines = [];
4393
4697
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
4394
4698
  lines.push(e.isDirectory() ? `${e.name}/` : e.name);
@@ -4431,7 +4735,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4431
4735
  if (depth > maxDepth) return;
4432
4736
  let entries;
4433
4737
  try {
4434
- entries = await fs.readdir(dir, { withFileTypes: true });
4738
+ entries = await fs3.readdir(dir, { withFileTypes: true });
4435
4739
  } catch {
4436
4740
  return;
4437
4741
  }
@@ -4466,7 +4770,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4466
4770
  lines.push(line);
4467
4771
  emitted++;
4468
4772
  if (e.isDirectory() && !skip) {
4469
- await walk2(pathMod.join(dir, e.name), depth + 1);
4773
+ await walk2(pathMod3.join(dir, e.name), depth + 1);
4470
4774
  }
4471
4775
  }
4472
4776
  };
@@ -4493,47 +4797,11 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4493
4797
  },
4494
4798
  required: ["pattern"]
4495
4799
  },
4496
- fn: async (args) => {
4497
- const startAbs = safePath(args.path ?? ".");
4498
- const needle = args.pattern.toLowerCase();
4499
- const includeDeps = args.include_deps === true;
4500
- let re = null;
4501
- try {
4502
- re = new RegExp(args.pattern, "i");
4503
- } catch {
4504
- re = null;
4505
- }
4506
- const matches = [];
4507
- let totalBytes = 0;
4508
- const walk2 = async (dir) => {
4509
- let entries;
4510
- try {
4511
- entries = await fs.readdir(dir, { withFileTypes: true });
4512
- } catch {
4513
- return;
4514
- }
4515
- for (const e of entries) {
4516
- const full = pathMod.join(dir, e.name);
4517
- const lower = e.name.toLowerCase();
4518
- const hit = re ? re.test(e.name) : lower.includes(needle);
4519
- if (hit) {
4520
- const rel = displayRel(rootDir, full);
4521
- if (totalBytes + rel.length + 1 > maxListBytes) {
4522
- matches.push("[\u2026 search truncated \u2014 refine pattern \u2026]");
4523
- return;
4524
- }
4525
- matches.push(rel);
4526
- totalBytes += rel.length + 1;
4527
- }
4528
- if (e.isDirectory()) {
4529
- if (!includeDeps && SKIP_DIR_NAMES.has(e.name)) continue;
4530
- await walk2(full);
4531
- }
4532
- }
4533
- };
4534
- await walk2(startAbs);
4535
- return matches.length === 0 ? "(no matches)" : matches.join("\n");
4536
- }
4800
+ fn: async (args) => searchFiles(
4801
+ { rootDir, maxListBytes, skipDirNames: SKIP_DIR_NAMES },
4802
+ safePath(args.path ?? "."),
4803
+ args
4804
+ )
4537
4805
  });
4538
4806
  registry.register({
4539
4807
  name: "search_content",
@@ -4565,83 +4833,17 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4565
4833
  },
4566
4834
  required: ["pattern"]
4567
4835
  },
4568
- fn: async (args) => {
4569
- const startAbs = safePath(args.path ?? ".");
4570
- const caseSensitive = args.case_sensitive === true;
4571
- const includeDeps = args.include_deps === true;
4572
- const nameMatch = compileNameFilter(typeof args.glob === "string" ? args.glob : null);
4573
- let re = null;
4574
- try {
4575
- re = new RegExp(args.pattern, caseSensitive ? "" : "i");
4576
- } catch {
4577
- re = null;
4578
- }
4579
- const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
4580
- const matches = [];
4581
- let totalBytes = 0;
4582
- let scanned = 0;
4583
- let truncated = false;
4584
- const walk2 = async (dir) => {
4585
- if (truncated) return;
4586
- let entries;
4587
- try {
4588
- entries = await fs.readdir(dir, { withFileTypes: true });
4589
- } catch {
4590
- return;
4591
- }
4592
- for (const e of entries) {
4593
- if (truncated) return;
4594
- if (e.isDirectory()) {
4595
- if (!includeDeps && SKIP_DIR_NAMES.has(e.name)) continue;
4596
- await walk2(pathMod.join(dir, e.name));
4597
- continue;
4598
- }
4599
- if (!e.isFile()) continue;
4600
- const full = pathMod.join(dir, e.name);
4601
- if (nameMatch && !nameMatch(e.name, displayRel(rootDir, full))) continue;
4602
- if (isLikelyBinaryByName(e.name)) continue;
4603
- let stat2;
4604
- try {
4605
- stat2 = await fs.stat(full);
4606
- } catch {
4607
- continue;
4608
- }
4609
- if (stat2.size > 2 * 1024 * 1024) continue;
4610
- let raw;
4611
- try {
4612
- raw = await fs.readFile(full);
4613
- } catch {
4614
- continue;
4615
- }
4616
- const firstNul = raw.indexOf(0);
4617
- if (firstNul !== -1 && firstNul < 8 * 1024) continue;
4618
- const text = raw.toString("utf8");
4619
- const rel = displayRel(rootDir, full);
4620
- const lines = text.split(/\r?\n/);
4621
- for (let li = 0; li < lines.length; li++) {
4622
- const line = lines[li];
4623
- const lineForCheck = caseSensitive ? line : line.toLowerCase();
4624
- const hit = re ? re.test(line) : lineForCheck.includes(needle);
4625
- if (!hit) continue;
4626
- const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
4627
- const out = `${rel}:${li + 1}: ${display}`;
4628
- if (totalBytes + out.length + 1 > maxListBytes) {
4629
- matches.push(`[\u2026 truncated at ${maxListBytes} bytes \u2014 refine pattern or path \u2026]`);
4630
- truncated = true;
4631
- return;
4632
- }
4633
- matches.push(out);
4634
- totalBytes += out.length + 1;
4635
- }
4636
- scanned++;
4637
- }
4638
- };
4639
- await walk2(startAbs);
4640
- if (matches.length === 0) {
4641
- return scanned === 0 ? "(no files scanned \u2014 path empty or all files filtered out)" : `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
4642
- }
4643
- return matches.join("\n");
4644
- }
4836
+ fn: async (args) => searchContent(
4837
+ {
4838
+ rootDir,
4839
+ maxListBytes,
4840
+ skipDirNames: SKIP_DIR_NAMES,
4841
+ isBinaryByName: isLikelyBinaryByName,
4842
+ nameMatch: compileNameFilter(typeof args.glob === "string" ? args.glob : null)
4843
+ },
4844
+ safePath(args.path ?? "."),
4845
+ args
4846
+ )
4645
4847
  });
4646
4848
  registry.register({
4647
4849
  name: "get_file_info",
@@ -4656,7 +4858,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4656
4858
  },
4657
4859
  fn: async (args) => {
4658
4860
  const abs = safePath(args.path);
4659
- const st = await fs.lstat(abs);
4861
+ const st = await fs3.lstat(abs);
4660
4862
  const type = st.isDirectory() ? "directory" : st.isSymbolicLink() ? "symlink" : "file";
4661
4863
  return JSON.stringify({
4662
4864
  type,
@@ -4679,9 +4881,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4679
4881
  },
4680
4882
  fn: async (args) => {
4681
4883
  const abs = safePath(args.path);
4682
- await fs.mkdir(pathMod.dirname(abs), { recursive: true });
4683
- await fs.writeFile(abs, args.content, "utf8");
4684
- return `wrote ${args.content.length} chars to ${displayRel(rootDir, abs)}`;
4884
+ await fs3.mkdir(pathMod3.dirname(abs), { recursive: true });
4885
+ await fs3.writeFile(abs, args.content, "utf8");
4886
+ return `wrote ${args.content.length} chars to ${displayRel3(rootDir, abs)}`;
4685
4887
  }
4686
4888
  });
4687
4889
  registry.register({
@@ -4696,34 +4898,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
4696
4898
  },
4697
4899
  required: ["path", "search", "replace"]
4698
4900
  },
4699
- fn: async (args) => {
4700
- const abs = safePath(args.path);
4701
- const before = await fs.readFile(abs, "utf8");
4702
- if (args.search.length === 0) {
4703
- throw new Error("edit_file: search cannot be empty");
4704
- }
4705
- const le = before.includes("\r\n") ? "\r\n" : "\n";
4706
- const adaptedSearch = args.search.replace(/\r?\n/g, le);
4707
- const adaptedReplace = args.replace.replace(/\r?\n/g, le);
4708
- const firstIdx = before.indexOf(adaptedSearch);
4709
- if (firstIdx < 0) {
4710
- throw new Error(`edit_file: search text not found in ${displayRel(rootDir, abs)}`);
4711
- }
4712
- const nextIdx = before.indexOf(adaptedSearch, firstIdx + 1);
4713
- if (nextIdx >= 0) {
4714
- throw new Error(
4715
- `edit_file: search text appears multiple times in ${displayRel(rootDir, abs)} \u2014 include more context to disambiguate`
4716
- );
4717
- }
4718
- const after = before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
4719
- await fs.writeFile(abs, after, "utf8");
4720
- const rel = displayRel(rootDir, abs);
4721
- const header = `edited ${rel} (${adaptedSearch.length}\u2192${adaptedReplace.length} chars)`;
4722
- const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
4723
- const diff = renderEditDiff(adaptedSearch, adaptedReplace, startLine);
4724
- return `${header}
4725
- ${diff}`;
4726
- }
4901
+ fn: async (args) => applyEdit(rootDir, safePath(args.path), args)
4727
4902
  });
4728
4903
  registry.register({
4729
4904
  name: "create_directory",
@@ -4735,8 +4910,8 @@ ${diff}`;
4735
4910
  },
4736
4911
  fn: async (args) => {
4737
4912
  const abs = safePath(args.path);
4738
- await fs.mkdir(abs, { recursive: true });
4739
- return `created ${displayRel(rootDir, abs)}/`;
4913
+ await fs3.mkdir(abs, { recursive: true });
4914
+ return `created ${displayRel3(rootDir, abs)}/`;
4740
4915
  }
4741
4916
  });
4742
4917
  registry.register({
@@ -4753,58 +4928,13 @@ ${diff}`;
4753
4928
  fn: async (args) => {
4754
4929
  const src = safePath(args.source);
4755
4930
  const dst = safePath(args.destination);
4756
- await fs.mkdir(pathMod.dirname(dst), { recursive: true });
4757
- await fs.rename(src, dst);
4758
- return `moved ${displayRel(rootDir, src)} \u2192 ${displayRel(rootDir, dst)}`;
4931
+ await fs3.mkdir(pathMod3.dirname(dst), { recursive: true });
4932
+ await fs3.rename(src, dst);
4933
+ return `moved ${displayRel3(rootDir, src)} \u2192 ${displayRel3(rootDir, dst)}`;
4759
4934
  }
4760
4935
  });
4761
4936
  return registry;
4762
4937
  }
4763
- function renderEditDiff(search, replace, startLine) {
4764
- const a = search.split(/\r?\n/);
4765
- const b = replace.split(/\r?\n/);
4766
- const diff = lineDiff(a, b);
4767
- const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
4768
- const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
4769
- return `${hunk}
4770
- ${body}`;
4771
- }
4772
- function lineDiff(a, b) {
4773
- const n = a.length;
4774
- const m = b.length;
4775
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
4776
- for (let i2 = 1; i2 <= n; i2++) {
4777
- for (let j2 = 1; j2 <= m; j2++) {
4778
- if (a[i2 - 1] === b[j2 - 1]) dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
4779
- else dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
4780
- }
4781
- }
4782
- const out = [];
4783
- let i = n;
4784
- let j = m;
4785
- while (i > 0 && j > 0) {
4786
- if (a[i - 1] === b[j - 1]) {
4787
- out.unshift({ op: " ", line: a[i - 1] });
4788
- i--;
4789
- j--;
4790
- } else if ((dp[i - 1][j] ?? 0) > (dp[i][j - 1] ?? 0)) {
4791
- out.unshift({ op: "-", line: a[i - 1] });
4792
- i--;
4793
- } else {
4794
- out.unshift({ op: "+", line: b[j - 1] });
4795
- j--;
4796
- }
4797
- }
4798
- while (i > 0) {
4799
- out.unshift({ op: "-", line: a[i - 1] });
4800
- i--;
4801
- }
4802
- while (j > 0) {
4803
- out.unshift({ op: "+", line: b[j - 1] });
4804
- j--;
4805
- }
4806
- return out;
4807
- }
4808
4938
 
4809
4939
  // src/tools/memory.ts
4810
4940
  function registerMemoryTools(registry, opts = {}) {
@@ -5522,16 +5652,14 @@ function forkRegistryExcluding(parent, exclude) {
5522
5652
  }
5523
5653
 
5524
5654
  // src/tools/shell.ts
5525
- import { spawn as spawn4, spawnSync } from "child_process";
5526
- import { existsSync as existsSync8, statSync as statSync4 } from "fs";
5527
- import * as pathMod4 from "path";
5655
+ import * as pathMod7 from "path";
5528
5656
 
5529
5657
  // src/config.ts
5530
5658
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "fs";
5531
5659
  import { homedir as homedir5 } from "os";
5532
- import { dirname as dirname4, join as join9 } from "path";
5660
+ import { dirname as dirname4, join as join10 } from "path";
5533
5661
  function defaultConfigPath() {
5534
- return join9(homedir5(), ".reasonix", "config.json");
5662
+ return join10(homedir5(), ".reasonix", "config.json");
5535
5663
  }
5536
5664
  function readConfig(path2 = defaultConfigPath()) {
5537
5665
  try {
@@ -5582,7 +5710,7 @@ function redactKey(key) {
5582
5710
 
5583
5711
  // src/tools/jobs.ts
5584
5712
  import { spawn as spawn2 } from "child_process";
5585
- import * as pathMod2 from "path";
5713
+ import * as pathMod4 from "path";
5586
5714
  function killProcessTree(pid, signal) {
5587
5715
  if (process.platform === "win32") {
5588
5716
  const args = ["/pid", String(pid), "/T"];
@@ -5642,7 +5770,7 @@ var JobRegistry = class {
5642
5770
  const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
5643
5771
  const { bin, args, spawnOverrides } = prepareSpawn(argv);
5644
5772
  const spawnOpts = {
5645
- cwd: pathMod2.resolve(opts.cwd),
5773
+ cwd: pathMod4.resolve(opts.cwd),
5646
5774
  shell: false,
5647
5775
  windowsHide: true,
5648
5776
  env: process.env,
@@ -5673,6 +5801,9 @@ var JobRegistry = class {
5673
5801
  child: null,
5674
5802
  readyPromise: Promise.resolve(),
5675
5803
  signalReady: () => {
5804
+ },
5805
+ closedPromise: Promise.resolve(),
5806
+ signalClosed: () => {
5676
5807
  }
5677
5808
  };
5678
5809
  this.jobs.set(id2, job2);
@@ -5691,6 +5822,11 @@ var JobRegistry = class {
5691
5822
  const readyPromise = new Promise((res) => {
5692
5823
  readyResolve = res;
5693
5824
  });
5825
+ let closedResolve = () => {
5826
+ };
5827
+ const closedPromise = new Promise((res) => {
5828
+ closedResolve = res;
5829
+ });
5694
5830
  const job = {
5695
5831
  id,
5696
5832
  command: trimmed,
@@ -5702,7 +5838,9 @@ var JobRegistry = class {
5702
5838
  running: true,
5703
5839
  child,
5704
5840
  readyPromise,
5705
- signalReady: readyResolve
5841
+ signalReady: readyResolve,
5842
+ closedPromise,
5843
+ signalClosed: closedResolve
5706
5844
  };
5707
5845
  this.jobs.set(id, job);
5708
5846
  let readyMatched = false;
@@ -5736,11 +5874,13 @@ ${job.output.slice(start)}`;
5736
5874
  job.running = false;
5737
5875
  job.spawnError = err.message;
5738
5876
  job.signalReady();
5877
+ job.signalClosed();
5739
5878
  });
5740
5879
  child.on("close", (code) => {
5741
5880
  job.running = false;
5742
5881
  job.exitCode = code;
5743
5882
  job.signalReady();
5883
+ job.signalClosed();
5744
5884
  });
5745
5885
  const onAbort = () => this.stop(id, { graceMs: 100 });
5746
5886
  if (opts.signal?.aborted) {
@@ -5802,7 +5942,7 @@ ${job.output.slice(start)}`;
5802
5942
  } catch {
5803
5943
  }
5804
5944
  }
5805
- await Promise.race([job.readyPromise, new Promise((res) => setTimeout(res, graceMs))]);
5945
+ await Promise.race([job.closedPromise, new Promise((res) => setTimeout(res, graceMs))]);
5806
5946
  if (job.running) {
5807
5947
  if (job.pid !== null) {
5808
5948
  killProcessTree(job.pid, "SIGKILL");
@@ -5812,7 +5952,7 @@ ${job.output.slice(start)}`;
5812
5952
  } catch {
5813
5953
  }
5814
5954
  }
5815
- await new Promise((res) => setTimeout(res, 800));
5955
+ await Promise.race([job.closedPromise, new Promise((res) => setTimeout(res, 5e3))]);
5816
5956
  }
5817
5957
  return snapshot(job);
5818
5958
  }
@@ -5868,10 +6008,15 @@ function snapshot(job) {
5868
6008
  };
5869
6009
  }
5870
6010
 
6011
+ // src/tools/shell/exec.ts
6012
+ import { spawn as spawn4, spawnSync } from "child_process";
6013
+ import { existsSync as existsSync8, statSync as statSync4 } from "fs";
6014
+ import * as pathMod6 from "path";
6015
+
5871
6016
  // src/tools/shell-chain.ts
5872
6017
  import { spawn as spawn3 } from "child_process";
5873
6018
  import { closeSync, openSync } from "fs";
5874
- import * as pathMod3 from "path";
6019
+ import * as pathMod5 from "path";
5875
6020
  var UnsupportedSyntaxError = class extends Error {
5876
6021
  constructor(detail) {
5877
6022
  super(`run_command: ${detail}`);
@@ -6138,7 +6283,7 @@ function openRedirects(redirects, cwd) {
6138
6283
  let bothFd = null;
6139
6284
  const toClose = [];
6140
6285
  const open = (target, flags) => {
6141
- const resolved = pathMod3.resolve(cwd, target);
6286
+ const resolved = pathMod5.resolve(cwd, target);
6142
6287
  const fd = openSync(resolved, flags);
6143
6288
  toClose.push(fd);
6144
6289
  return fd;
@@ -6287,31 +6432,7 @@ var OutputBuffer = class {
6287
6432
  }
6288
6433
  };
6289
6434
 
6290
- // src/tools/shell.ts
6291
- function killProcessTree2(child) {
6292
- if (!child.pid || child.killed) return;
6293
- if (process.platform === "win32") {
6294
- try {
6295
- spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
6296
- stdio: "ignore",
6297
- windowsHide: true
6298
- });
6299
- return;
6300
- } catch {
6301
- }
6302
- }
6303
- try {
6304
- process.kill(-child.pid, "SIGKILL");
6305
- return;
6306
- } catch {
6307
- }
6308
- try {
6309
- child.kill("SIGKILL");
6310
- } catch {
6311
- }
6312
- }
6313
- var DEFAULT_TIMEOUT_SEC = 60;
6314
- var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
6435
+ // src/tools/shell/parse.ts
6315
6436
  var BUILTIN_ALLOWLIST = [
6316
6437
  // Repo inspection
6317
6438
  "git status",
@@ -6524,6 +6645,32 @@ function isCommandAllowed(cmd, extra = []) {
6524
6645
  if (chain === null) return isAllowed(cmd, extra);
6525
6646
  return chainAllowed(chain, (seg) => isAllowed(seg, extra));
6526
6647
  }
6648
+
6649
+ // src/tools/shell/exec.ts
6650
+ var DEFAULT_TIMEOUT_SEC = 60;
6651
+ var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
6652
+ function killProcessTree2(child) {
6653
+ if (!child.pid || child.killed) return;
6654
+ if (process.platform === "win32") {
6655
+ try {
6656
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
6657
+ stdio: "ignore",
6658
+ windowsHide: true
6659
+ });
6660
+ return;
6661
+ } catch {
6662
+ }
6663
+ }
6664
+ try {
6665
+ process.kill(-child.pid, "SIGKILL");
6666
+ return;
6667
+ } catch {
6668
+ }
6669
+ try {
6670
+ child.kill("SIGKILL");
6671
+ } catch {
6672
+ }
6673
+ }
6527
6674
  async function runCommand(cmd, opts) {
6528
6675
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
6529
6676
  const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
@@ -6632,16 +6779,16 @@ function resolveExecutable(cmd, opts = {}) {
6632
6779
  const platform = opts.platform ?? process.platform;
6633
6780
  if (platform !== "win32") return cmd;
6634
6781
  if (!cmd) return cmd;
6635
- if (cmd.includes("/") || cmd.includes("\\") || pathMod4.isAbsolute(cmd)) return cmd;
6636
- if (pathMod4.extname(cmd)) return cmd;
6782
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod6.isAbsolute(cmd)) return cmd;
6783
+ if (pathMod6.extname(cmd)) return cmd;
6637
6784
  const env = opts.env ?? process.env;
6638
6785
  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
6639
- const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod4.delimiter);
6786
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod6.delimiter);
6640
6787
  const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
6641
6788
  const isFile = opts.isFile ?? defaultIsFile;
6642
6789
  for (const dir of pathDirs) {
6643
6790
  for (const ext of pathExt) {
6644
- const full = pathMod4.win32.join(dir, cmd + ext);
6791
+ const full = pathMod6.win32.join(dir, cmd + ext);
6645
6792
  if (isFile(full)) return full;
6646
6793
  }
6647
6794
  }
@@ -6711,8 +6858,8 @@ function withUtf8Codepage(cmdline) {
6711
6858
  function isBareWindowsName(s) {
6712
6859
  if (!s) return false;
6713
6860
  if (s.includes("/") || s.includes("\\")) return false;
6714
- if (pathMod4.isAbsolute(s)) return false;
6715
- if (pathMod4.extname(s)) return false;
6861
+ if (pathMod6.isAbsolute(s)) return false;
6862
+ if (pathMod6.extname(s)) return false;
6716
6863
  return true;
6717
6864
  }
6718
6865
  function quoteForCmdExe(arg) {
@@ -6720,6 +6867,8 @@ function quoteForCmdExe(arg) {
6720
6867
  if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
6721
6868
  return `"${arg.replace(/"/g, '""')}"`;
6722
6869
  }
6870
+
6871
+ // src/tools/shell.ts
6723
6872
  var NeedsConfirmationError = class extends Error {
6724
6873
  command;
6725
6874
  constructor(command) {
@@ -6731,7 +6880,7 @@ var NeedsConfirmationError = class extends Error {
6731
6880
  }
6732
6881
  };
6733
6882
  function registerShellTools(registry, opts) {
6734
- const rootDir = pathMod4.resolve(opts.rootDir);
6883
+ const rootDir = pathMod7.resolve(opts.rootDir);
6735
6884
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
6736
6885
  const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
6737
6886
  const jobs = opts.jobs ?? new JobRegistry();
@@ -6930,6 +7079,7 @@ ${r.output}` : header;
6930
7079
  }
6931
7080
 
6932
7081
  // src/tools/web.ts
7082
+ import { parse as parseHtml } from "node-html-parser";
6933
7083
  var DEFAULT_FETCH_MAX_CHARS = 32e3;
6934
7084
  var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
6935
7085
  var DEFAULT_TOPK = 5;
@@ -7059,28 +7209,70 @@ async function readBodyCapped(resp, maxBytes) {
7059
7209
  }
7060
7210
  return out;
7061
7211
  }
7212
+ var MAX_HTML_INPUT = 5 * 1024 * 1024;
7213
+ var STRIP_BLOCK_TAGS = "script, style, noscript, nav, footer, aside, svg";
7214
+ var BLOCK_BREAK_TAGS = /* @__PURE__ */ new Set([
7215
+ "p",
7216
+ "div",
7217
+ "br",
7218
+ "h1",
7219
+ "h2",
7220
+ "h3",
7221
+ "h4",
7222
+ "h5",
7223
+ "h6",
7224
+ "li",
7225
+ "tr",
7226
+ "section",
7227
+ "article"
7228
+ ]);
7062
7229
  function htmlToText(html) {
7063
- let s = html;
7064
- s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
7065
- s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
7066
- s = s.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
7067
- s = s.replace(/<nav[\s\S]*?<\/nav>/gi, "");
7068
- s = s.replace(/<footer[\s\S]*?<\/footer>/gi, "");
7069
- s = s.replace(/<aside[\s\S]*?<\/aside>/gi, "");
7070
- s = s.replace(/<svg[\s\S]*?<\/svg>/gi, "");
7071
- s = s.replace(/<\/?(p|div|br|h[1-6]|li|tr|section|article)\b[^>]*>/gi, "\n");
7072
- s = s.replace(/<[^>]+>/g, "");
7230
+ const input = html.length > MAX_HTML_INPUT ? html.slice(0, MAX_HTML_INPUT) : html;
7231
+ const root = parseHtml(input);
7232
+ for (const node of root.querySelectorAll(STRIP_BLOCK_TAGS)) node.remove();
7233
+ const out = [];
7234
+ walkExtract(root, out);
7235
+ let s = out.join("");
7073
7236
  s = decodeHtmlEntities(s);
7074
7237
  s = s.replace(/[ \t]+/g, " ");
7075
7238
  s = s.replace(/\n[ \t]+/g, "\n");
7076
7239
  s = s.replace(/\n{3,}/g, "\n\n");
7077
7240
  return s.trim();
7078
7241
  }
7079
- function stripHtml(s) {
7080
- return s.replace(/<[^>]+>/g, "");
7242
+ function walkExtract(node, out) {
7243
+ if (node.nodeType === 3) {
7244
+ out.push(node.rawText ?? node.text ?? "");
7245
+ return;
7246
+ }
7247
+ const tag = node.rawTagName?.toLowerCase();
7248
+ const isBreak = tag !== void 0 && BLOCK_BREAK_TAGS.has(tag);
7249
+ if (isBreak) out.push("\n");
7250
+ for (const child of node.childNodes) walkExtract(child, out);
7251
+ if (isBreak) out.push("\n");
7081
7252
  }
7253
+ function stripHtml(s) {
7254
+ return parseHtml(s).text;
7255
+ }
7256
+ var HTML_ENTITIES = {
7257
+ amp: "&",
7258
+ lt: "<",
7259
+ gt: ">",
7260
+ quot: '"',
7261
+ apos: "'",
7262
+ nbsp: " "
7263
+ };
7082
7264
  function decodeHtmlEntities(s) {
7083
- return s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
7265
+ return s.replace(/&(#\d+|#x[0-9a-fA-F]+|\w+);/g, (raw, name) => {
7266
+ if (name.startsWith("#x") || name.startsWith("#X")) {
7267
+ const code = Number.parseInt(name.slice(2), 16);
7268
+ return Number.isFinite(code) ? String.fromCodePoint(code) : raw;
7269
+ }
7270
+ if (name.startsWith("#")) {
7271
+ const code = Number.parseInt(name.slice(1), 10);
7272
+ return Number.isFinite(code) ? String.fromCodePoint(code) : raw;
7273
+ }
7274
+ return HTML_ENTITIES[name.toLowerCase()] ?? raw;
7275
+ });
7084
7276
  }
7085
7277
  function extractTitle(html) {
7086
7278
  const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
@@ -7675,7 +7867,7 @@ function truncate(s, n) {
7675
7867
  // src/version.ts
7676
7868
  import { existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync12, writeFileSync as writeFileSync4 } from "fs";
7677
7869
  import { homedir as homedir6 } from "os";
7678
- import { dirname as dirname5, join as join10 } from "path";
7870
+ import { dirname as dirname5, join as join11 } from "path";
7679
7871
  import { fileURLToPath as fileURLToPath2 } from "url";
7680
7872
  var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
7681
7873
  var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -7684,7 +7876,7 @@ function readPackageVersion() {
7684
7876
  try {
7685
7877
  let dir = dirname5(fileURLToPath2(import.meta.url));
7686
7878
  for (let i = 0; i < 6; i++) {
7687
- const p = join10(dir, "package.json");
7879
+ const p = join11(dir, "package.json");
7688
7880
  if (existsSync9(p)) {
7689
7881
  const pkg = JSON.parse(readFileSync12(p, "utf8"));
7690
7882
  if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
@@ -7701,7 +7893,7 @@ function readPackageVersion() {
7701
7893
  }
7702
7894
  var VERSION = readPackageVersion();
7703
7895
  function cachePath(homeDirOverride) {
7704
- return join10(homeDirOverride ?? homedir6(), ".reasonix", "version-cache.json");
7896
+ return join11(homeDirOverride ?? homedir6(), ".reasonix", "version-cache.json");
7705
7897
  }
7706
7898
  function readCache(homeDirOverride) {
7707
7899
  try {
@@ -8517,7 +8709,19 @@ async function trySection(load) {
8517
8709
  }
8518
8710
 
8519
8711
  // src/code/edit-blocks.ts
8520
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync13, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
8712
+ import {
8713
+ closeSync as closeSync2,
8714
+ existsSync as existsSync10,
8715
+ fstatSync,
8716
+ ftruncateSync,
8717
+ mkdirSync as mkdirSync5,
8718
+ openSync as openSync2,
8719
+ readFileSync as readFileSync13,
8720
+ readSync,
8721
+ unlinkSync as unlinkSync3,
8722
+ writeFileSync as writeFileSync5,
8723
+ writeSync
8724
+ } from "fs";
8521
8725
  import { dirname as dirname6, resolve as resolve9 } from "path";
8522
8726
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
8523
8727
  function parseEditBlocks(text) {
@@ -8546,42 +8750,76 @@ function applyEditBlock(block, rootDir) {
8546
8750
  };
8547
8751
  }
8548
8752
  const searchEmpty = block.search.length === 0;
8549
- const exists = existsSync10(absTarget);
8753
+ if (searchEmpty) {
8754
+ try {
8755
+ mkdirSync5(dirname6(absTarget), { recursive: true });
8756
+ const fd = openSync2(absTarget, "wx");
8757
+ try {
8758
+ writeSync(fd, block.replace);
8759
+ } finally {
8760
+ closeSync2(fd);
8761
+ }
8762
+ return { path: block.path, status: "created" };
8763
+ } catch (err) {
8764
+ const e = err;
8765
+ if (e.code === "EEXIST") {
8766
+ return {
8767
+ path: block.path,
8768
+ status: "not-found",
8769
+ message: "empty SEARCH only creates new files \u2014 this file already exists"
8770
+ };
8771
+ }
8772
+ return { path: block.path, status: "error", message: e.message };
8773
+ }
8774
+ }
8550
8775
  try {
8551
- if (!exists) {
8552
- if (!searchEmpty) {
8776
+ let fd;
8777
+ try {
8778
+ fd = openSync2(absTarget, "r+");
8779
+ } catch (err) {
8780
+ if (err.code === "ENOENT") {
8553
8781
  return {
8554
8782
  path: block.path,
8555
8783
  status: "file-missing",
8556
8784
  message: "file does not exist; to create it, use an empty SEARCH block"
8557
8785
  };
8558
8786
  }
8559
- mkdirSync5(dirname6(absTarget), { recursive: true });
8560
- writeFileSync5(absTarget, block.replace, "utf8");
8561
- return { path: block.path, status: "created" };
8562
- }
8563
- const content = readFileSync13(absTarget, "utf8");
8564
- if (searchEmpty) {
8565
- return {
8566
- path: block.path,
8567
- status: "not-found",
8568
- message: "empty SEARCH only creates new files \u2014 this file already exists"
8569
- };
8787
+ throw err;
8570
8788
  }
8571
- const le = lineEndingOf(content);
8572
- const adaptedSearch = block.search.replace(/\r?\n/g, le);
8573
- const adaptedReplace = block.replace.replace(/\r?\n/g, le);
8574
- const idx = content.indexOf(adaptedSearch);
8575
- if (idx === -1) {
8576
- return {
8577
- path: block.path,
8578
- status: "not-found",
8579
- message: "SEARCH text does not match the current file content exactly"
8580
- };
8789
+ try {
8790
+ const stat2 = fstatSync(fd);
8791
+ const inBuf = Buffer.alloc(stat2.size);
8792
+ let readBytes = 0;
8793
+ while (readBytes < stat2.size) {
8794
+ const n = readSync(fd, inBuf, readBytes, stat2.size - readBytes, readBytes);
8795
+ if (n <= 0) break;
8796
+ readBytes += n;
8797
+ }
8798
+ const content = inBuf.toString("utf8", 0, readBytes);
8799
+ const le = lineEndingOf(content);
8800
+ const adaptedSearch = block.search.replace(/\r?\n/g, le);
8801
+ const adaptedReplace = block.replace.replace(/\r?\n/g, le);
8802
+ const idx = content.indexOf(adaptedSearch);
8803
+ if (idx === -1) {
8804
+ return {
8805
+ path: block.path,
8806
+ status: "not-found",
8807
+ message: "SEARCH text does not match the current file content exactly"
8808
+ };
8809
+ }
8810
+ const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
8811
+ const outBuf = Buffer.from(replaced, "utf8");
8812
+ ftruncateSync(fd, outBuf.length);
8813
+ let written = 0;
8814
+ while (written < outBuf.length) {
8815
+ const n = writeSync(fd, outBuf, written, outBuf.length - written, written);
8816
+ if (n <= 0) break;
8817
+ written += n;
8818
+ }
8819
+ return { path: block.path, status: "applied" };
8820
+ } finally {
8821
+ closeSync2(fd);
8581
8822
  }
8582
- const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
8583
- writeFileSync5(absTarget, replaced, "utf8");
8584
- return { path: block.path, status: "applied" };
8585
8823
  } catch (err) {
8586
8824
  return { path: block.path, status: "error", message: err.message };
8587
8825
  }
@@ -8649,7 +8887,7 @@ function lineEndingOf(text) {
8649
8887
 
8650
8888
  // src/code/prompt.ts
8651
8889
  import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
8652
- import { join as join11 } from "path";
8890
+ import { join as join12 } from "path";
8653
8891
  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.
8654
8892
 
8655
8893
  # Cite or shut up \u2014 non-negotiable
@@ -8851,7 +9089,7 @@ If \`semantic_search\` returns nothing useful (low scores, off-topic), THEN fall
8851
9089
  function codeSystemPrompt(rootDir, opts = {}) {
8852
9090
  const base = opts.hasSemanticSearch ? `${CODE_SYSTEM_PROMPT}${SEMANTIC_SEARCH_ROUTING}` : CODE_SYSTEM_PROMPT;
8853
9091
  const withMemory = applyMemoryStack(base, rootDir);
8854
- const gitignorePath = join11(rootDir, ".gitignore");
9092
+ const gitignorePath = join12(rootDir, ".gitignore");
8855
9093
  let result = withMemory;
8856
9094
  if (existsSync11(gitignorePath)) {
8857
9095
  let content;
@@ -8889,34 +9127,47 @@ ${appendParts.join("\n\n")}`;
8889
9127
  // src/telemetry/usage.ts
8890
9128
  import {
8891
9129
  appendFileSync as appendFileSync2,
9130
+ closeSync as closeSync3,
8892
9131
  existsSync as existsSync12,
9132
+ fstatSync as fstatSync2,
8893
9133
  mkdirSync as mkdirSync6,
9134
+ openSync as openSync3,
8894
9135
  readFileSync as readFileSync15,
9136
+ readSync as readSync2,
9137
+ renameSync as renameSync2,
8895
9138
  statSync as statSync5,
9139
+ unlinkSync as unlinkSync4,
8896
9140
  writeFileSync as writeFileSync6
8897
9141
  } from "fs";
8898
9142
  import { homedir as homedir7 } from "os";
8899
- import { dirname as dirname7, join as join12 } from "path";
9143
+ import { dirname as dirname7, join as join13 } from "path";
8900
9144
  function defaultUsageLogPath(homeDirOverride) {
8901
- return join12(homeDirOverride ?? homedir7(), ".reasonix", "usage.jsonl");
9145
+ return join13(homeDirOverride ?? homedir7(), ".reasonix", "usage.jsonl");
8902
9146
  }
8903
9147
  var USAGE_COMPACTION_THRESHOLD_BYTES = 5 * 1024 * 1024;
8904
9148
  var USAGE_RETENTION_DAYS = 365;
8905
9149
  function compactUsageLogIfLarge(path2, now) {
8906
- let size;
8907
- try {
8908
- size = statSync5(path2).size;
8909
- } catch {
8910
- return;
8911
- }
8912
- if (size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
8913
- const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
8914
9150
  let raw;
8915
9151
  try {
8916
- raw = readFileSync15(path2, "utf8");
9152
+ const fd = openSync3(path2, "r");
9153
+ try {
9154
+ const stat2 = fstatSync2(fd);
9155
+ if (stat2.size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
9156
+ const buf = Buffer.alloc(stat2.size);
9157
+ let read = 0;
9158
+ while (read < stat2.size) {
9159
+ const n = readSync2(fd, buf, read, stat2.size - read, read);
9160
+ if (n <= 0) break;
9161
+ read += n;
9162
+ }
9163
+ raw = buf.toString("utf8", 0, read);
9164
+ } finally {
9165
+ closeSync3(fd);
9166
+ }
8917
9167
  } catch {
8918
9168
  return;
8919
9169
  }
9170
+ const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
8920
9171
  const lines = raw.split(/\r?\n/);
8921
9172
  const kept = [];
8922
9173
  for (const line of lines) {
@@ -8928,10 +9179,16 @@ function compactUsageLogIfLarge(path2, now) {
8928
9179
  }
8929
9180
  }
8930
9181
  if (kept.length === lines.filter((l) => l.trim()).length) return;
9182
+ const tmp = `${path2}.compacting`;
8931
9183
  try {
8932
- writeFileSync6(path2, kept.length > 0 ? `${kept.join("\n")}
9184
+ writeFileSync6(tmp, kept.length > 0 ? `${kept.join("\n")}
8933
9185
  ` : "", "utf8");
9186
+ renameSync2(tmp, path2);
8934
9187
  } catch {
9188
+ try {
9189
+ unlinkSync4(tmp);
9190
+ } catch {
9191
+ }
8935
9192
  }
8936
9193
  }
8937
9194
  function appendUsage(input) {