reasonix 0.4.17 → 0.4.20

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
@@ -47,8 +47,8 @@ function computeWait(attempt, initial, cap, retryAfter) {
47
47
  }
48
48
  function sleep(ms, signal) {
49
49
  if (ms <= 0) return Promise.resolve();
50
- return new Promise((resolve5, reject) => {
51
- const timer = setTimeout(resolve5, ms);
50
+ return new Promise((resolve6, reject) => {
51
+ const timer = setTimeout(resolve6, ms);
52
52
  if (signal) {
53
53
  const onAbort = () => {
54
54
  clearTimeout(timer);
@@ -499,9 +499,25 @@ function setByPath(target, path, value) {
499
499
  var ToolRegistry = class {
500
500
  _tools = /* @__PURE__ */ new Map();
501
501
  _autoFlatten;
502
+ /**
503
+ * When true, `dispatch` refuses any tool whose `readOnly` flag isn't
504
+ * set (and whose `readOnlyCheck` doesn't pass on the specific args).
505
+ * Drives `reasonix code`'s Plan Mode — the model can still explore
506
+ * via read tools but its writes and non-allowlisted shell calls are
507
+ * bounced until the user approves a submitted plan.
508
+ */
509
+ _planMode = false;
502
510
  constructor(opts = {}) {
503
511
  this._autoFlatten = opts.autoFlatten !== false;
504
512
  }
513
+ /** Enable / disable plan-mode enforcement at dispatch. */
514
+ setPlanMode(on) {
515
+ this._planMode = Boolean(on);
516
+ }
517
+ /** True when the registry is currently refusing non-readonly calls. */
518
+ get planMode() {
519
+ return this._planMode;
520
+ }
505
521
  register(def) {
506
522
  if (!def.name) throw new Error("tool requires a name");
507
523
  const internal = { ...def };
@@ -553,16 +569,38 @@ var ToolRegistry = class {
553
569
  if (tool.flatSchema && args && typeof args === "object" && hasDotKey(args)) {
554
570
  args = nestArguments(args);
555
571
  }
572
+ if (this._planMode && !isReadOnlyCall(tool, args)) {
573
+ return JSON.stringify({
574
+ error: `${name}: unavailable in plan mode \u2014 this is a read-only exploration phase. Use read_file / list_directory / search_files / directory_tree / web_search / allowlisted shell commands to investigate. Call submit_plan with your proposed plan when you're ready for the user's review.`
575
+ });
576
+ }
556
577
  try {
557
578
  const result = await tool.fn(args, { signal: opts.signal });
558
579
  return typeof result === "string" ? result : JSON.stringify(result);
559
580
  } catch (err) {
581
+ const e = err;
582
+ if (typeof e.toToolResult === "function") {
583
+ try {
584
+ return JSON.stringify(e.toToolResult());
585
+ } catch {
586
+ }
587
+ }
560
588
  return JSON.stringify({
561
- error: `${err.name}: ${err.message}`
589
+ error: `${e.name}: ${e.message}`
562
590
  });
563
591
  }
564
592
  }
565
593
  };
594
+ function isReadOnlyCall(tool, args) {
595
+ if (tool.readOnlyCheck) {
596
+ try {
597
+ return Boolean(tool.readOnlyCheck(args));
598
+ } catch {
599
+ return false;
600
+ }
601
+ }
602
+ return tool.readOnly === true;
603
+ }
566
604
  function hasDotKey(obj) {
567
605
  for (const k of Object.keys(obj)) {
568
606
  if (k.includes(".")) return true;
@@ -949,6 +987,16 @@ var ToolCallRepair = class {
949
987
  this.opts = opts;
950
988
  this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3);
951
989
  }
990
+ /**
991
+ * Drop the StormBreaker's sliding window of recent (name, args)
992
+ * signatures. Called at the start of every user turn — a fresh user
993
+ * message is a new intent, so carrying old repetition state into it
994
+ * would turn a valid "try again with different input" flow into a
995
+ * false-positive block.
996
+ */
997
+ resetStorm() {
998
+ this.storm.reset();
999
+ }
952
1000
  process(declaredCalls, reasoningContent, content = null) {
953
1001
  const report = {
954
1002
  scavenged: 0,
@@ -1401,6 +1449,7 @@ var CacheFirstLoop = class {
1401
1449
  async *step(userInput) {
1402
1450
  this._turn++;
1403
1451
  this.scratch.reset();
1452
+ this.repair.resetStorm();
1404
1453
  this._turnAbort = new AbortController();
1405
1454
  const signal = this._turnAbort.signal;
1406
1455
  let pendingUser = userInput;
@@ -1488,8 +1537,8 @@ var CacheFirstLoop = class {
1488
1537
  }
1489
1538
  );
1490
1539
  for (let k = 0; k < budget; k++) {
1491
- const sample = queue.shift() ?? await new Promise((resolve5) => {
1492
- waiter = resolve5;
1540
+ const sample = queue.shift() ?? await new Promise((resolve6) => {
1541
+ waiter = resolve6;
1493
1542
  });
1494
1543
  yield {
1495
1544
  turn: this._turn,
@@ -1624,6 +1673,16 @@ var CacheFirstLoop = class {
1624
1673
  repair: report,
1625
1674
  branch: branchSummary
1626
1675
  };
1676
+ if (report.stormsBroken > 0) {
1677
+ const noteTail = report.notes.length ? ` \u2014 ${report.notes[report.notes.length - 1]}` : "";
1678
+ const allSuppressed = repairedCalls.length === 0 && toolCalls.length > 0;
1679
+ const phrase = allSuppressed ? `stopped the model from calling the same tool with identical args repeatedly (all ${toolCalls.length} call(s) this turn were already in the recent-repeat window). Likely a stuck retry \u2014 reword your instruction, rule out the underlying blocker, or try /retry after fixing it` : `suppressed ${report.stormsBroken} repeat tool call(s) that had fired 3+ times with identical args in a sliding window`;
1680
+ yield {
1681
+ turn: this._turn,
1682
+ role: "warning",
1683
+ content: `${phrase}${noteTail}`
1684
+ };
1685
+ }
1627
1686
  if (repairedCalls.length === 0) {
1628
1687
  yield { turn: this._turn, role: "done", content: assistantContent };
1629
1688
  return;
@@ -1891,6 +1950,291 @@ ${mem.content}
1891
1950
  `;
1892
1951
  }
1893
1952
 
1953
+ // src/user-memory.ts
1954
+ import { createHash as createHash2 } from "crypto";
1955
+ import {
1956
+ existsSync as existsSync3,
1957
+ mkdirSync as mkdirSync2,
1958
+ readFileSync as readFileSync3,
1959
+ readdirSync as readdirSync2,
1960
+ unlinkSync as unlinkSync2,
1961
+ writeFileSync as writeFileSync2
1962
+ } from "fs";
1963
+ import { homedir as homedir2 } from "os";
1964
+ import { join as join3, resolve } from "path";
1965
+ var USER_MEMORY_DIR = "memory";
1966
+ var MEMORY_INDEX_FILE = "MEMORY.md";
1967
+ var MEMORY_INDEX_MAX_CHARS = 4e3;
1968
+ var VALID_NAME = /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]{1,38}[a-zA-Z0-9]$/;
1969
+ function sanitizeMemoryName(raw) {
1970
+ const trimmed = String(raw ?? "").trim();
1971
+ if (!VALID_NAME.test(trimmed)) {
1972
+ throw new Error(
1973
+ `invalid memory name: ${JSON.stringify(raw)} \u2014 must be 3-40 chars, alnum/_/-, no path separators`
1974
+ );
1975
+ }
1976
+ return trimmed;
1977
+ }
1978
+ function projectHash(rootDir) {
1979
+ const abs = resolve(rootDir);
1980
+ return createHash2("sha1").update(abs).digest("hex").slice(0, 16);
1981
+ }
1982
+ function scopeDir(opts) {
1983
+ if (opts.scope === "global") {
1984
+ return join3(opts.homeDir, USER_MEMORY_DIR, "global");
1985
+ }
1986
+ if (!opts.projectRoot) {
1987
+ throw new Error("scope=project requires a projectRoot on MemoryStore");
1988
+ }
1989
+ return join3(opts.homeDir, USER_MEMORY_DIR, projectHash(opts.projectRoot));
1990
+ }
1991
+ function ensureDir(p) {
1992
+ if (!existsSync3(p)) mkdirSync2(p, { recursive: true });
1993
+ }
1994
+ function parseFrontmatter(raw) {
1995
+ const lines = raw.split(/\r?\n/);
1996
+ if (lines[0] !== "---") return { data: {}, body: raw };
1997
+ const end = lines.indexOf("---", 1);
1998
+ if (end < 0) return { data: {}, body: raw };
1999
+ const data = {};
2000
+ for (let i = 1; i < end; i++) {
2001
+ const line = lines[i];
2002
+ if (!line) continue;
2003
+ const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
2004
+ if (m?.[1]) data[m[1]] = (m[2] ?? "").trim();
2005
+ }
2006
+ return {
2007
+ data,
2008
+ body: lines.slice(end + 1).join("\n").replace(/^\n+/, "")
2009
+ };
2010
+ }
2011
+ function formatFrontmatter(e) {
2012
+ return [
2013
+ "---",
2014
+ `name: ${e.name}`,
2015
+ `description: ${e.description.replace(/\n/g, " ")}`,
2016
+ `type: ${e.type}`,
2017
+ `scope: ${e.scope}`,
2018
+ `created: ${e.createdAt}`,
2019
+ "---",
2020
+ ""
2021
+ ].join("\n");
2022
+ }
2023
+ function todayIso() {
2024
+ const d = /* @__PURE__ */ new Date();
2025
+ return d.toISOString().slice(0, 10);
2026
+ }
2027
+ function indexLine(e) {
2028
+ const safeDesc = e.description.replace(/\n/g, " ").trim();
2029
+ const max = 130 - e.name.length;
2030
+ const clipped = safeDesc.length > max ? `${safeDesc.slice(0, Math.max(1, max - 1))}\u2026` : safeDesc;
2031
+ return `- [${e.name}](${e.name}.md) \u2014 ${clipped}`;
2032
+ }
2033
+ var MemoryStore = class {
2034
+ homeDir;
2035
+ projectRoot;
2036
+ constructor(opts = {}) {
2037
+ this.homeDir = opts.homeDir ?? join3(homedir2(), ".reasonix");
2038
+ this.projectRoot = opts.projectRoot ? resolve(opts.projectRoot) : void 0;
2039
+ }
2040
+ /** Directory this store writes `scope` files into, creating it if needed. */
2041
+ dir(scope) {
2042
+ const d = scopeDir({ homeDir: this.homeDir, scope, projectRoot: this.projectRoot });
2043
+ ensureDir(d);
2044
+ return d;
2045
+ }
2046
+ /** Absolute path to a memory file (no existence check). */
2047
+ pathFor(scope, name) {
2048
+ return join3(this.dir(scope), `${sanitizeMemoryName(name)}.md`);
2049
+ }
2050
+ /** True iff this store is configured with a project scope available. */
2051
+ hasProjectScope() {
2052
+ return this.projectRoot !== void 0;
2053
+ }
2054
+ /**
2055
+ * Read the `MEMORY.md` index for a scope. Returns post-cap content
2056
+ * (with a truncation marker if clipped), or `null` when absent / empty.
2057
+ */
2058
+ loadIndex(scope) {
2059
+ if (scope === "project" && !this.projectRoot) return null;
2060
+ const file = join3(
2061
+ scopeDir({ homeDir: this.homeDir, scope, projectRoot: this.projectRoot }),
2062
+ MEMORY_INDEX_FILE
2063
+ );
2064
+ if (!existsSync3(file)) return null;
2065
+ let raw;
2066
+ try {
2067
+ raw = readFileSync3(file, "utf8");
2068
+ } catch {
2069
+ return null;
2070
+ }
2071
+ const trimmed = raw.trim();
2072
+ if (!trimmed) return null;
2073
+ const originalChars = trimmed.length;
2074
+ const truncated = originalChars > MEMORY_INDEX_MAX_CHARS;
2075
+ const content = truncated ? `${trimmed.slice(0, MEMORY_INDEX_MAX_CHARS)}
2076
+ \u2026 (truncated ${originalChars - MEMORY_INDEX_MAX_CHARS} chars)` : trimmed;
2077
+ return { content, originalChars, truncated };
2078
+ }
2079
+ /** Read one memory file's body (frontmatter stripped). Throws if missing. */
2080
+ read(scope, name) {
2081
+ const file = this.pathFor(scope, name);
2082
+ if (!existsSync3(file)) {
2083
+ throw new Error(`memory not found: scope=${scope} name=${name}`);
2084
+ }
2085
+ const raw = readFileSync3(file, "utf8");
2086
+ const { data, body } = parseFrontmatter(raw);
2087
+ return {
2088
+ name: data.name ?? name,
2089
+ type: data.type ?? "project",
2090
+ scope: data.scope ?? scope,
2091
+ description: data.description ?? "",
2092
+ body: body.trim(),
2093
+ createdAt: data.created ?? ""
2094
+ };
2095
+ }
2096
+ /**
2097
+ * List every memory in this store. Scans both scopes (skips project
2098
+ * scope if unconfigured). Silently skips malformed files; the index
2099
+ * must stay queryable even if one file is hand-edited into nonsense.
2100
+ */
2101
+ list() {
2102
+ const out = [];
2103
+ const scopes = this.projectRoot ? ["global", "project"] : ["global"];
2104
+ for (const scope of scopes) {
2105
+ const dir = scopeDir({ homeDir: this.homeDir, scope, projectRoot: this.projectRoot });
2106
+ if (!existsSync3(dir)) continue;
2107
+ let entries;
2108
+ try {
2109
+ entries = readdirSync2(dir);
2110
+ } catch {
2111
+ continue;
2112
+ }
2113
+ for (const entry of entries) {
2114
+ if (entry === MEMORY_INDEX_FILE) continue;
2115
+ if (!entry.endsWith(".md")) continue;
2116
+ const name = entry.slice(0, -3);
2117
+ try {
2118
+ out.push(this.read(scope, name));
2119
+ } catch {
2120
+ }
2121
+ }
2122
+ }
2123
+ return out;
2124
+ }
2125
+ /**
2126
+ * Write a new memory (or overwrite existing). Creates the scope dir,
2127
+ * writes the `.md` file, and regenerates `MEMORY.md`. Returns the
2128
+ * absolute path written to.
2129
+ */
2130
+ write(input) {
2131
+ if (input.scope === "project" && !this.projectRoot) {
2132
+ throw new Error("cannot write project-scoped memory: no projectRoot configured");
2133
+ }
2134
+ const name = sanitizeMemoryName(input.name);
2135
+ const desc = String(input.description ?? "").trim();
2136
+ if (!desc) throw new Error("memory description cannot be empty");
2137
+ const body = String(input.body ?? "").trim();
2138
+ if (!body) throw new Error("memory body cannot be empty");
2139
+ const entry = {
2140
+ ...input,
2141
+ name,
2142
+ description: desc,
2143
+ body,
2144
+ createdAt: todayIso()
2145
+ };
2146
+ const dir = this.dir(input.scope);
2147
+ const file = join3(dir, `${name}.md`);
2148
+ const content = `${formatFrontmatter(entry)}${body}
2149
+ `;
2150
+ writeFileSync2(file, content, "utf8");
2151
+ this.regenerateIndex(input.scope);
2152
+ return file;
2153
+ }
2154
+ /** Delete one memory + its index line. No-op if the file is already gone. */
2155
+ delete(scope, rawName) {
2156
+ if (scope === "project" && !this.projectRoot) {
2157
+ throw new Error("cannot delete project-scoped memory: no projectRoot configured");
2158
+ }
2159
+ const file = this.pathFor(scope, rawName);
2160
+ if (!existsSync3(file)) return false;
2161
+ unlinkSync2(file);
2162
+ this.regenerateIndex(scope);
2163
+ return true;
2164
+ }
2165
+ /**
2166
+ * Rebuild `MEMORY.md` from the `.md` files currently in the scope dir.
2167
+ * Called after every write/delete. Sorted by name for stable prefix
2168
+ * hashing — two stores with the same set of files produce byte-identical
2169
+ * MEMORY.md content, keeping the cache prefix reproducible.
2170
+ */
2171
+ regenerateIndex(scope) {
2172
+ const dir = scopeDir({ homeDir: this.homeDir, scope, projectRoot: this.projectRoot });
2173
+ if (!existsSync3(dir)) return;
2174
+ let files;
2175
+ try {
2176
+ files = readdirSync2(dir);
2177
+ } catch {
2178
+ return;
2179
+ }
2180
+ const mdFiles = files.filter((f) => f !== MEMORY_INDEX_FILE && f.endsWith(".md")).sort((a, b) => a.localeCompare(b));
2181
+ const indexPath = join3(dir, MEMORY_INDEX_FILE);
2182
+ if (mdFiles.length === 0) {
2183
+ if (existsSync3(indexPath)) unlinkSync2(indexPath);
2184
+ return;
2185
+ }
2186
+ const lines = [];
2187
+ for (const f of mdFiles) {
2188
+ const name = f.slice(0, -3);
2189
+ try {
2190
+ const entry = this.read(scope, name);
2191
+ lines.push(indexLine({ name: entry.name || name, description: entry.description }));
2192
+ } catch {
2193
+ lines.push(`- [${name}](${name}.md) \u2014 (malformed, check frontmatter)`);
2194
+ }
2195
+ }
2196
+ writeFileSync2(indexPath, `${lines.join("\n")}
2197
+ `, "utf8");
2198
+ }
2199
+ };
2200
+ function applyUserMemory(basePrompt, opts = {}) {
2201
+ if (!memoryEnabled()) return basePrompt;
2202
+ const store = new MemoryStore(opts);
2203
+ const global = store.loadIndex("global");
2204
+ const project = store.hasProjectScope() ? store.loadIndex("project") : null;
2205
+ if (!global && !project) return basePrompt;
2206
+ const parts = [basePrompt];
2207
+ if (global) {
2208
+ parts.push(
2209
+ "",
2210
+ "# User memory \u2014 global (~/.reasonix/memory/global/MEMORY.md)",
2211
+ "",
2212
+ "Cross-project facts and preferences the user has told you in prior sessions. TREAT AS AUTHORITATIVE \u2014 don't re-verify via filesystem or web. One-liners index detail files; call `recall_memory` for full bodies only when the one-liner isn't enough.",
2213
+ "",
2214
+ "```",
2215
+ global.content,
2216
+ "```"
2217
+ );
2218
+ }
2219
+ if (project) {
2220
+ parts.push(
2221
+ "",
2222
+ "# User memory \u2014 this project",
2223
+ "",
2224
+ "Per-project facts the user established in prior sessions (not committed to the repo). TREAT AS AUTHORITATIVE. Same recall pattern as global memory.",
2225
+ "",
2226
+ "```",
2227
+ project.content,
2228
+ "```"
2229
+ );
2230
+ }
2231
+ return parts.join("\n");
2232
+ }
2233
+ function applyMemoryStack(basePrompt, rootDir) {
2234
+ const withProject = applyProjectMemory(basePrompt, rootDir);
2235
+ return applyUserMemory(withProject, { projectRoot: rootDir });
2236
+ }
2237
+
1894
2238
  // src/tools/filesystem.ts
1895
2239
  import { promises as fs } from "fs";
1896
2240
  import * as pathMod from "path";
@@ -1916,6 +2260,7 @@ function registerFilesystemTools(registry, opts) {
1916
2260
  registry.register({
1917
2261
  name: "read_file",
1918
2262
  description: "Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
2263
+ readOnly: true,
1919
2264
  parameters: {
1920
2265
  type: "object",
1921
2266
  properties: {
@@ -1953,6 +2298,7 @@ function registerFilesystemTools(registry, opts) {
1953
2298
  registry.register({
1954
2299
  name: "list_directory",
1955
2300
  description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
2301
+ readOnly: true,
1956
2302
  parameters: {
1957
2303
  type: "object",
1958
2304
  properties: {
@@ -1972,6 +2318,7 @@ function registerFilesystemTools(registry, opts) {
1972
2318
  registry.register({
1973
2319
  name: "directory_tree",
1974
2320
  description: "Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
2321
+ readOnly: true,
1975
2322
  parameters: {
1976
2323
  type: "object",
1977
2324
  properties: {
@@ -2018,6 +2365,7 @@ function registerFilesystemTools(registry, opts) {
2018
2365
  registry.register({
2019
2366
  name: "search_files",
2020
2367
  description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
2368
+ readOnly: true,
2021
2369
  parameters: {
2022
2370
  type: "object",
2023
2371
  properties: {
@@ -2070,6 +2418,7 @@ function registerFilesystemTools(registry, opts) {
2070
2418
  registry.register({
2071
2419
  name: "get_file_info",
2072
2420
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
2421
+ readOnly: true,
2073
2422
  parameters: {
2074
2423
  type: "object",
2075
2424
  properties: {
@@ -2109,7 +2458,7 @@ function registerFilesystemTools(registry, opts) {
2109
2458
  });
2110
2459
  registry.register({
2111
2460
  name: "edit_file",
2112
- description: "Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) \u2014 no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites. This flat-string shape replaces the `{oldText, newText}[]` JSON array form that previously triggered R1 DSML hallucinations.",
2461
+ description: "Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) \u2014 no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites.",
2113
2462
  parameters: {
2114
2463
  type: "object",
2115
2464
  properties: {
@@ -2226,8 +2575,175 @@ function lineDiff(a, b) {
2226
2575
  return out;
2227
2576
  }
2228
2577
 
2578
+ // src/tools/memory.ts
2579
+ function registerMemoryTools(registry, opts = {}) {
2580
+ const store = new MemoryStore({ homeDir: opts.homeDir, projectRoot: opts.projectRoot });
2581
+ const hasProject = store.hasProjectScope();
2582
+ registry.register({
2583
+ name: "remember",
2584
+ description: "Save a memory for future sessions. Use when the user states a preference, corrects your approach, shares a non-obvious fact about this project, or explicitly asks you to remember something. Don't remember transient task state \u2014 only things worth recalling next session. The memory is written now but won't re-load into the system prompt until the next `/new` or launch.",
2585
+ parameters: {
2586
+ type: "object",
2587
+ properties: {
2588
+ type: {
2589
+ type: "string",
2590
+ enum: ["user", "feedback", "project", "reference"],
2591
+ description: "'user' = role/skills/prefs; 'feedback' = corrections or confirmed approaches; 'project' = facts/decisions about the current work; 'reference' = pointers to external systems the user uses."
2592
+ },
2593
+ scope: {
2594
+ type: "string",
2595
+ enum: ["global", "project"],
2596
+ description: "'global' = applies across every project (preferences, tooling); 'project' = scoped to the current sandbox (decisions, local facts). Only available in `reasonix code`."
2597
+ },
2598
+ name: {
2599
+ type: "string",
2600
+ description: "filename-safe identifier, 3-40 chars, alnum + _ - . (no path separators, no leading dot)."
2601
+ },
2602
+ description: {
2603
+ type: "string",
2604
+ description: "One-line summary shown in MEMORY.md (under ~150 chars)."
2605
+ },
2606
+ content: {
2607
+ type: "string",
2608
+ description: "Full memory body in markdown. For feedback/project types, structure as: rule/fact, then **Why:** line, then **How to apply:** line."
2609
+ }
2610
+ },
2611
+ required: ["type", "scope", "name", "description", "content"]
2612
+ },
2613
+ fn: async (args) => {
2614
+ if (args.scope === "project" && !hasProject) {
2615
+ return JSON.stringify({
2616
+ error: "scope='project' is unavailable in this session (no sandbox root). Retry with scope='global', or ask the user to switch to `reasonix code` for project-scoped memory."
2617
+ });
2618
+ }
2619
+ try {
2620
+ const path = store.write({
2621
+ name: args.name,
2622
+ type: args.type,
2623
+ scope: args.scope,
2624
+ description: args.description,
2625
+ body: args.content
2626
+ });
2627
+ const key = sanitizeMemoryName(args.name);
2628
+ return [
2629
+ `\u2713 REMEMBERED (${args.scope}/${key}): ${args.description}`,
2630
+ "",
2631
+ "TREAT THIS AS ESTABLISHED FACT for the rest of this session.",
2632
+ "The user just told you \u2014 don't re-explore the filesystem to re-derive it.",
2633
+ `(Saved to ${path}; pins into the system prompt on next /new or launch.)`
2634
+ ].join("\n");
2635
+ } catch (err) {
2636
+ return JSON.stringify({ error: `remember failed: ${err.message}` });
2637
+ }
2638
+ }
2639
+ });
2640
+ registry.register({
2641
+ name: "forget",
2642
+ description: "Delete a memory file and remove it from MEMORY.md. Use when the user explicitly asks to forget something, or when a previously-remembered fact has become wrong. Irreversible \u2014 no tombstone.",
2643
+ parameters: {
2644
+ type: "object",
2645
+ properties: {
2646
+ name: { type: "string", description: "Memory name (the identifier used in `remember`)." },
2647
+ scope: { type: "string", enum: ["global", "project"] }
2648
+ },
2649
+ required: ["name", "scope"]
2650
+ },
2651
+ fn: async (args) => {
2652
+ if (args.scope === "project" && !hasProject) {
2653
+ return JSON.stringify({
2654
+ error: "scope='project' is unavailable in this session (no sandbox root)."
2655
+ });
2656
+ }
2657
+ try {
2658
+ const existed = store.delete(args.scope, args.name);
2659
+ return existed ? `forgot (${args.scope}/${sanitizeMemoryName(args.name)}). Re-load on next /new or launch.` : `no such memory: ${args.scope}/${args.name} (nothing to forget).`;
2660
+ } catch (err) {
2661
+ return JSON.stringify({ error: `forget failed: ${err.message}` });
2662
+ }
2663
+ }
2664
+ });
2665
+ registry.register({
2666
+ name: "recall_memory",
2667
+ description: "Read the full body of a memory file when its MEMORY.md one-liner (already in the system prompt) isn't enough detail. Most of the time the index suffices \u2014 only call this when the user's question genuinely requires the full context.",
2668
+ readOnly: true,
2669
+ parameters: {
2670
+ type: "object",
2671
+ properties: {
2672
+ name: { type: "string" },
2673
+ scope: { type: "string", enum: ["global", "project"] }
2674
+ },
2675
+ required: ["name", "scope"]
2676
+ },
2677
+ fn: async (args) => {
2678
+ if (args.scope === "project" && !hasProject) {
2679
+ return JSON.stringify({
2680
+ error: "scope='project' is unavailable in this session (no sandbox root)."
2681
+ });
2682
+ }
2683
+ try {
2684
+ const entry = store.read(args.scope, args.name);
2685
+ return [
2686
+ `# ${entry.name} (${entry.scope}/${entry.type}, created ${entry.createdAt || "?"})`,
2687
+ entry.description ? `> ${entry.description}` : "",
2688
+ "",
2689
+ entry.body
2690
+ ].filter(Boolean).join("\n");
2691
+ } catch (err) {
2692
+ return JSON.stringify({ error: `recall failed: ${err.message}` });
2693
+ }
2694
+ }
2695
+ });
2696
+ return registry;
2697
+ }
2698
+
2699
+ // src/tools/plan.ts
2700
+ var PlanProposedError = class extends Error {
2701
+ plan;
2702
+ constructor(plan) {
2703
+ super(
2704
+ "PlanProposedError: plan submitted. STOP calling tools now \u2014 the TUI has shown the plan to the user. Wait for their next message; it will either approve (you'll then implement the plan), request a refinement (you should explore more and submit an updated plan), or cancel (drop the plan and ask what they want instead). Don't call any tools in the meantime."
2705
+ );
2706
+ this.name = "PlanProposedError";
2707
+ this.plan = plan;
2708
+ }
2709
+ /**
2710
+ * Structured tool-result shape. Consumed by the TUI to extract the
2711
+ * plan without regex-scraping the error message.
2712
+ */
2713
+ toToolResult() {
2714
+ return { error: `${this.name}: ${this.message}`, plan: this.plan };
2715
+ }
2716
+ };
2717
+ function registerPlanTool(registry, opts = {}) {
2718
+ registry.register({
2719
+ name: "submit_plan",
2720
+ description: "Submit a concrete plan to the user for review before executing. Use this for tasks that warrant a review gate \u2014 multi-file refactors, architecture changes, anything that would be expensive or confusing to undo. Skip it for small fixes (one-line typo, obvious bug with a clear fix) \u2014 just make the change. The user will either approve (you then implement it), ask for refinement, or cancel. If the user has already enabled /plan mode, writes are blocked at dispatch and you MUST use this. Write the plan as markdown with a one-line summary, a bulleted list of files to touch and what will change, and any risks or open questions.",
2721
+ readOnly: true,
2722
+ parameters: {
2723
+ type: "object",
2724
+ properties: {
2725
+ plan: {
2726
+ type: "string",
2727
+ description: "Markdown-formatted plan. Lead with a one-sentence summary. Then a file-by-file breakdown of what you'll change and why. Flag any risks or open questions at the end so the user can weigh in before you start."
2728
+ }
2729
+ },
2730
+ required: ["plan"]
2731
+ },
2732
+ fn: async (args) => {
2733
+ const plan = (args?.plan ?? "").trim();
2734
+ if (!plan) {
2735
+ throw new Error("submit_plan: empty plan \u2014 write a markdown plan and try again.");
2736
+ }
2737
+ opts.onPlanSubmitted?.(plan);
2738
+ throw new PlanProposedError(plan);
2739
+ }
2740
+ });
2741
+ return registry;
2742
+ }
2743
+
2229
2744
  // src/tools/shell.ts
2230
2745
  import { spawn } from "child_process";
2746
+ import { existsSync as existsSync4, statSync as statSync2 } from "fs";
2231
2747
  import * as pathMod2 from "path";
2232
2748
  var DEFAULT_TIMEOUT_SEC = 60;
2233
2749
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
@@ -2345,10 +2861,12 @@ async function runCommand(cmd, opts) {
2345
2861
  windowsHide: true,
2346
2862
  env: process.env
2347
2863
  };
2348
- return await new Promise((resolve5, reject) => {
2864
+ const { bin, args, spawnOverrides } = prepareSpawn(argv);
2865
+ const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
2866
+ return await new Promise((resolve6, reject) => {
2349
2867
  let child;
2350
2868
  try {
2351
- child = spawn(argv[0], argv.slice(1), spawnOpts);
2869
+ child = spawn(bin, args, effectiveSpawnOpts);
2352
2870
  } catch (err) {
2353
2871
  reject(err);
2354
2872
  return;
@@ -2378,10 +2896,63 @@ async function runCommand(cmd, opts) {
2378
2896
  const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
2379
2897
 
2380
2898
  [\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
2381
- resolve5({ exitCode: code, output, timedOut });
2899
+ resolve6({ exitCode: code, output, timedOut });
2382
2900
  });
2383
2901
  });
2384
2902
  }
2903
+ function resolveExecutable(cmd, opts = {}) {
2904
+ const platform = opts.platform ?? process.platform;
2905
+ if (platform !== "win32") return cmd;
2906
+ if (!cmd) return cmd;
2907
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
2908
+ if (pathMod2.extname(cmd)) return cmd;
2909
+ const env = opts.env ?? process.env;
2910
+ const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
2911
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
2912
+ const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
2913
+ const isFile = opts.isFile ?? defaultIsFile;
2914
+ for (const dir of pathDirs) {
2915
+ for (const ext of pathExt) {
2916
+ const full = pathMod2.win32.join(dir, cmd + ext);
2917
+ if (isFile(full)) return full;
2918
+ }
2919
+ }
2920
+ return cmd;
2921
+ }
2922
+ function defaultIsFile(full) {
2923
+ try {
2924
+ return existsSync4(full) && statSync2(full).isFile();
2925
+ } catch {
2926
+ return false;
2927
+ }
2928
+ }
2929
+ function prepareSpawn(argv, opts = {}) {
2930
+ const head = argv[0] ?? "";
2931
+ const tail = argv.slice(1);
2932
+ const platform = opts.platform ?? process.platform;
2933
+ const resolved = resolveExecutable(head, opts);
2934
+ if (platform !== "win32") {
2935
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2936
+ }
2937
+ if (/\.(cmd|bat)$/i.test(resolved)) {
2938
+ const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
2939
+ return {
2940
+ bin: "cmd.exe",
2941
+ args: ["/d", "/s", "/c", cmdline],
2942
+ // windowsVerbatimArguments prevents Node from re-quoting the /c
2943
+ // payload — we've already composed an exact cmd.exe command
2944
+ // line. Without this Node wraps our already-quoted string in
2945
+ // another round of quotes and cmd.exe can't parse it.
2946
+ spawnOverrides: { windowsVerbatimArguments: true }
2947
+ };
2948
+ }
2949
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2950
+ }
2951
+ function quoteForCmdExe(arg) {
2952
+ if (arg === "") return '""';
2953
+ if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
2954
+ return `"${arg.replace(/"/g, '""')}"`;
2955
+ }
2385
2956
  var NeedsConfirmationError = class extends Error {
2386
2957
  command;
2387
2958
  constructor(command) {
@@ -2400,13 +2971,23 @@ function registerShellTools(registry, opts) {
2400
2971
  const allowAll = opts.allowAll ?? false;
2401
2972
  registry.register({
2402
2973
  name: "run_command",
2403
- description: "Run a shell command in the project root and return its combined stdout+stderr. Read-only and test commands (git status, ls, npm test, pytest, cargo test, grep, etc.) run immediately. Anything that could mutate state (npm install, git commit, rm, chmod) is refused and the user has to confirm in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
2974
+ description: "Run a shell command in the project root and return its combined stdout+stderr. Common read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
2975
+ // Plan-mode gate: allow allowlisted commands through (git status,
2976
+ // cargo check, ls, grep …) so the model can actually investigate
2977
+ // during planning. Anything that would otherwise trigger a
2978
+ // confirmation prompt is treated as "not read-only" and bounced.
2979
+ readOnlyCheck: (args) => {
2980
+ if (allowAll) return true;
2981
+ const cmd = typeof args?.command === "string" ? args.command.trim() : "";
2982
+ if (!cmd) return false;
2983
+ return isAllowed(cmd, extraAllowed);
2984
+ },
2404
2985
  parameters: {
2405
2986
  type: "object",
2406
2987
  properties: {
2407
2988
  command: {
2408
2989
  type: "string",
2409
- description: "Full command line, e.g. 'npm test' or 'git diff src/foo.ts'. Tokenized with POSIX-ish quoting; no shell expansion, no pipes, no redirects."
2990
+ description: "Full command line. Tokenized with POSIX-ish quoting; no shell expansion, no pipes, no redirects."
2410
2991
  },
2411
2992
  timeoutSec: {
2412
2993
  type: "integer",
@@ -2567,6 +3148,7 @@ function registerWebTools(registry, opts = {}) {
2567
3148
  registry.register({
2568
3149
  name: "web_search",
2569
3150
  description: "Search the public web. Returns ranked results with title, url, and snippet. Use this when the question needs information more current than your training data, when you're unsure of a factual detail, or when the user asks about a specific webpage/library/release you haven't seen.",
3151
+ readOnly: true,
2570
3152
  parameters: {
2571
3153
  type: "object",
2572
3154
  properties: {
@@ -2589,6 +3171,7 @@ function registerWebTools(registry, opts = {}) {
2589
3171
  registry.register({
2590
3172
  name: "web_fetch",
2591
3173
  description: "Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
3174
+ readOnly: true,
2592
3175
  parameters: {
2593
3176
  type: "object",
2594
3177
  properties: {
@@ -2623,12 +3206,12 @@ ${i + 1}. ${r.title}`);
2623
3206
  }
2624
3207
 
2625
3208
  // src/env.ts
2626
- import { readFileSync as readFileSync3 } from "fs";
2627
- import { resolve as resolve3 } from "path";
3209
+ import { readFileSync as readFileSync4 } from "fs";
3210
+ import { resolve as resolve4 } from "path";
2628
3211
  function loadDotenv(path = ".env") {
2629
3212
  let raw;
2630
3213
  try {
2631
- raw = readFileSync3(resolve3(process.cwd(), path), "utf8");
3214
+ raw = readFileSync4(resolve4(process.cwd(), path), "utf8");
2632
3215
  } catch {
2633
3216
  return;
2634
3217
  }
@@ -2647,7 +3230,7 @@ function loadDotenv(path = ".env") {
2647
3230
  }
2648
3231
 
2649
3232
  // src/transcript.ts
2650
- import { createWriteStream, readFileSync as readFileSync4 } from "fs";
3233
+ import { createWriteStream, readFileSync as readFileSync5 } from "fs";
2651
3234
  function recordFromLoopEvent(ev, extra) {
2652
3235
  const rec = {
2653
3236
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2698,7 +3281,7 @@ function openTranscriptFile(path, meta) {
2698
3281
  return stream;
2699
3282
  }
2700
3283
  function readTranscript(path) {
2701
- const raw = readFileSync4(path, "utf8");
3284
+ const raw = readFileSync5(path, "utf8");
2702
3285
  return parseTranscript(raw);
2703
3286
  }
2704
3287
  function isPlanStateEmptyShape(s) {
@@ -3310,7 +3893,7 @@ var McpClient = class {
3310
3893
  const id = this.nextId++;
3311
3894
  const frame = { jsonrpc: "2.0", id, method, params };
3312
3895
  let abortHandler = null;
3313
- const promise = new Promise((resolve5, reject) => {
3896
+ const promise = new Promise((resolve6, reject) => {
3314
3897
  const timeout = setTimeout(() => {
3315
3898
  this.pending.delete(id);
3316
3899
  if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
@@ -3319,7 +3902,7 @@ var McpClient = class {
3319
3902
  );
3320
3903
  }, this.requestTimeoutMs);
3321
3904
  this.pending.set(id, {
3322
- resolve: resolve5,
3905
+ resolve: resolve6,
3323
3906
  reject,
3324
3907
  timeout
3325
3908
  });
@@ -3442,12 +4025,12 @@ var StdioTransport = class {
3442
4025
  }
3443
4026
  async send(message) {
3444
4027
  if (this.closed) throw new Error("MCP transport is closed");
3445
- return new Promise((resolve5, reject) => {
4028
+ return new Promise((resolve6, reject) => {
3446
4029
  const line = `${JSON.stringify(message)}
3447
4030
  `;
3448
4031
  this.child.stdin.write(line, "utf8", (err) => {
3449
4032
  if (err) reject(err);
3450
- else resolve5();
4033
+ else resolve6();
3451
4034
  });
3452
4035
  });
3453
4036
  }
@@ -3458,8 +4041,8 @@ var StdioTransport = class {
3458
4041
  continue;
3459
4042
  }
3460
4043
  if (this.closed) return;
3461
- const next = await new Promise((resolve5) => {
3462
- this.waiters.push(resolve5);
4044
+ const next = await new Promise((resolve6) => {
4045
+ this.waiters.push(resolve6);
3463
4046
  });
3464
4047
  if (next === null) return;
3465
4048
  yield next;
@@ -3525,8 +4108,8 @@ var SseTransport = class {
3525
4108
  constructor(opts) {
3526
4109
  this.url = opts.url;
3527
4110
  this.headers = opts.headers ?? {};
3528
- this.endpointReady = new Promise((resolve5, reject) => {
3529
- this.resolveEndpoint = resolve5;
4111
+ this.endpointReady = new Promise((resolve6, reject) => {
4112
+ this.resolveEndpoint = resolve6;
3530
4113
  this.rejectEndpoint = reject;
3531
4114
  });
3532
4115
  this.endpointReady.catch(() => void 0);
@@ -3553,8 +4136,8 @@ var SseTransport = class {
3553
4136
  continue;
3554
4137
  }
3555
4138
  if (this.closed) return;
3556
- const next = await new Promise((resolve5) => {
3557
- this.waiters.push(resolve5);
4139
+ const next = await new Promise((resolve6) => {
4140
+ this.waiters.push(resolve6);
3558
4141
  });
3559
4142
  if (next === null) return;
3560
4143
  yield next;
@@ -3753,8 +4336,8 @@ async function trySection(load) {
3753
4336
  }
3754
4337
 
3755
4338
  // src/code/edit-blocks.ts
3756
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
3757
- import { dirname as dirname3, resolve as resolve4 } from "path";
4339
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync6, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "fs";
4340
+ import { dirname as dirname3, resolve as resolve5 } from "path";
3758
4341
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
3759
4342
  function parseEditBlocks(text) {
3760
4343
  const out = [];
@@ -3772,8 +4355,8 @@ function parseEditBlocks(text) {
3772
4355
  return out;
3773
4356
  }
3774
4357
  function applyEditBlock(block, rootDir) {
3775
- const absRoot = resolve4(rootDir);
3776
- const absTarget = resolve4(absRoot, block.path);
4358
+ const absRoot = resolve5(rootDir);
4359
+ const absTarget = resolve5(absRoot, block.path);
3777
4360
  if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
3778
4361
  return {
3779
4362
  path: block.path,
@@ -3782,7 +4365,7 @@ function applyEditBlock(block, rootDir) {
3782
4365
  };
3783
4366
  }
3784
4367
  const searchEmpty = block.search.length === 0;
3785
- const exists = existsSync3(absTarget);
4368
+ const exists = existsSync5(absTarget);
3786
4369
  try {
3787
4370
  if (!exists) {
3788
4371
  if (!searchEmpty) {
@@ -3792,11 +4375,11 @@ function applyEditBlock(block, rootDir) {
3792
4375
  message: "file does not exist; to create it, use an empty SEARCH block"
3793
4376
  };
3794
4377
  }
3795
- mkdirSync2(dirname3(absTarget), { recursive: true });
3796
- writeFileSync2(absTarget, block.replace, "utf8");
4378
+ mkdirSync3(dirname3(absTarget), { recursive: true });
4379
+ writeFileSync3(absTarget, block.replace, "utf8");
3797
4380
  return { path: block.path, status: "created" };
3798
4381
  }
3799
- const content = readFileSync5(absTarget, "utf8");
4382
+ const content = readFileSync6(absTarget, "utf8");
3800
4383
  if (searchEmpty) {
3801
4384
  return {
3802
4385
  path: block.path,
@@ -3813,7 +4396,7 @@ function applyEditBlock(block, rootDir) {
3813
4396
  };
3814
4397
  }
3815
4398
  const replaced = `${content.slice(0, idx)}${block.replace}${content.slice(idx + block.search.length)}`;
3816
- writeFileSync2(absTarget, replaced, "utf8");
4399
+ writeFileSync3(absTarget, replaced, "utf8");
3817
4400
  return { path: block.path, status: "applied" };
3818
4401
  } catch (err) {
3819
4402
  return { path: block.path, status: "error", message: err.message };
@@ -3823,19 +4406,19 @@ function applyEditBlocks(blocks, rootDir) {
3823
4406
  return blocks.map((b) => applyEditBlock(b, rootDir));
3824
4407
  }
3825
4408
  function snapshotBeforeEdits(blocks, rootDir) {
3826
- const absRoot = resolve4(rootDir);
4409
+ const absRoot = resolve5(rootDir);
3827
4410
  const seen = /* @__PURE__ */ new Set();
3828
4411
  const snapshots = [];
3829
4412
  for (const b of blocks) {
3830
4413
  if (seen.has(b.path)) continue;
3831
4414
  seen.add(b.path);
3832
- const abs = resolve4(absRoot, b.path);
3833
- if (!existsSync3(abs)) {
4415
+ const abs = resolve5(absRoot, b.path);
4416
+ if (!existsSync5(abs)) {
3834
4417
  snapshots.push({ path: b.path, prevContent: null });
3835
4418
  continue;
3836
4419
  }
3837
4420
  try {
3838
- snapshots.push({ path: b.path, prevContent: readFileSync5(abs, "utf8") });
4421
+ snapshots.push({ path: b.path, prevContent: readFileSync6(abs, "utf8") });
3839
4422
  } catch {
3840
4423
  snapshots.push({ path: b.path, prevContent: null });
3841
4424
  }
@@ -3843,9 +4426,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
3843
4426
  return snapshots;
3844
4427
  }
3845
4428
  function restoreSnapshots(snapshots, rootDir) {
3846
- const absRoot = resolve4(rootDir);
4429
+ const absRoot = resolve5(rootDir);
3847
4430
  return snapshots.map((snap) => {
3848
- const abs = resolve4(absRoot, snap.path);
4431
+ const abs = resolve5(absRoot, snap.path);
3849
4432
  if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
3850
4433
  return {
3851
4434
  path: snap.path,
@@ -3855,14 +4438,14 @@ function restoreSnapshots(snapshots, rootDir) {
3855
4438
  }
3856
4439
  try {
3857
4440
  if (snap.prevContent === null) {
3858
- if (existsSync3(abs)) unlinkSync2(abs);
4441
+ if (existsSync5(abs)) unlinkSync3(abs);
3859
4442
  return {
3860
4443
  path: snap.path,
3861
4444
  status: "applied",
3862
4445
  message: "removed (the edit had created it)"
3863
4446
  };
3864
4447
  }
3865
- writeFileSync2(abs, snap.prevContent, "utf8");
4448
+ writeFileSync3(abs, snap.prevContent, "utf8");
3866
4449
  return {
3867
4450
  path: snap.path,
3868
4451
  status: "applied",
@@ -3878,10 +4461,31 @@ function sep() {
3878
4461
  }
3879
4462
 
3880
4463
  // src/code/prompt.ts
3881
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
3882
- import { join as join4 } from "path";
4464
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
4465
+ import { join as join5 } from "path";
3883
4466
  var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
3884
4467
 
4468
+ # When to propose a plan (submit_plan)
4469
+
4470
+ You have a \`submit_plan\` tool that shows the user a markdown plan and lets them Approve / Refine / Cancel before you execute. Use it proactively when the task is large enough to deserve a review gate:
4471
+
4472
+ - Multi-file refactors or renames.
4473
+ - Architecture changes (moving modules, splitting / merging files, new abstractions).
4474
+ - Anything where "undo" after the fact would be expensive \u2014 migrations, destructive cleanups, API shape changes.
4475
+ - When the user's request is ambiguous and multiple reasonable interpretations exist \u2014 propose your reading as a plan and let them confirm.
4476
+
4477
+ Skip submit_plan for small, obvious changes: one-line typo, clear bug with a clear fix, adding a missing import, renaming a local variable. Just do those.
4478
+
4479
+ Plan body: one-sentence summary, then a file-by-file breakdown of what you'll change and why, and any risks or open questions. If some decisions are genuinely up to the user (naming, tradeoffs, out-of-scope possibilities), list them in an "Open questions" section \u2014 the user sees the plan in a picker and has a text input to answer your questions before approving. Don't pretend certainty you don't have; flagged questions are how the user tells you what they care about. After calling submit_plan, STOP \u2014 don't call any more tools, wait for the user's verdict.
4480
+
4481
+ # Plan mode (/plan)
4482
+
4483
+ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit constraint:
4484
+ - Write tools (edit_file, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch \u2014 you'll get a tool result like "unavailable in plan mode". Don't retry them.
4485
+ - Read tools (read_file, list_directory, search_files, directory_tree, get_file_info) and allowlisted read-only / test shell commands still work \u2014 use them to investigate.
4486
+ - You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
4487
+
4488
+
3885
4489
  # When to edit vs. when to explore
3886
4490
 
3887
4491
  Only propose edits when the user explicitly asks you to change, fix, add, remove, refactor, or write something. Do NOT propose edits when the user asks you to:
@@ -3916,9 +4520,13 @@ Rules:
3916
4520
  - Do NOT use write_file to change existing files \u2014 the user reviews your edits as SEARCH/REPLACE. write_file is only for files you explicitly want to overwrite wholesale (rare).
3917
4521
  - Paths are relative to the working directory. Don't use absolute paths.
3918
4522
 
4523
+ # Trust what you already know
4524
+
4525
+ Before exploring the filesystem to answer a factual question, check whether the answer is already in context: the user's current message, earlier turns in this conversation (including prior tool results from \`remember\`), and the pinned memory blocks at the top of this prompt. When the user has stated a fact or you have remembered one, it outranks what the files say \u2014 don't re-derive from code what the user already told you. Explore when you genuinely don't know.
4526
+
3919
4527
  # Exploration
3920
4528
 
3921
- - Avoid listing or reading inside these common dependency / build directories unless the user explicitly asks about them: node_modules, dist, build, out, .next, .nuxt, .svelte-kit, .git, .venv, venv, __pycache__, target, coverage, .turbo, .cache. They're expensive and usually irrelevant.
4529
+ - Skip dependency, build, and VCS directories unless the user explicitly asks. The pinned .gitignore block (if any, below) is your authoritative denylist.
3922
4530
  - Prefer search_files / grep over list_directory when you know roughly what you're looking for \u2014 it saves context and avoids enumerating huge trees.
3923
4531
 
3924
4532
  # Style
@@ -3928,12 +4536,12 @@ Rules:
3928
4536
  - If you need to explore first (list / grep / read), do it with tool calls before writing any prose \u2014 silence while exploring is fine.
3929
4537
  `;
3930
4538
  function codeSystemPrompt(rootDir) {
3931
- const withMemory = applyProjectMemory(CODE_SYSTEM_PROMPT, rootDir);
3932
- const gitignorePath = join4(rootDir, ".gitignore");
3933
- if (!existsSync4(gitignorePath)) return withMemory;
4539
+ const withMemory = applyMemoryStack(CODE_SYSTEM_PROMPT, rootDir);
4540
+ const gitignorePath = join5(rootDir, ".gitignore");
4541
+ if (!existsSync6(gitignorePath)) return withMemory;
3934
4542
  let content;
3935
4543
  try {
3936
- content = readFileSync6(gitignorePath, "utf8");
4544
+ content = readFileSync7(gitignorePath, "utf8");
3937
4545
  } catch {
3938
4546
  return withMemory;
3939
4547
  }
@@ -3953,15 +4561,15 @@ ${truncated}
3953
4561
  }
3954
4562
 
3955
4563
  // src/config.ts
3956
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
3957
- import { homedir as homedir2 } from "os";
3958
- import { dirname as dirname4, join as join5 } from "path";
4564
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
4565
+ import { homedir as homedir3 } from "os";
4566
+ import { dirname as dirname4, join as join6 } from "path";
3959
4567
  function defaultConfigPath() {
3960
- return join5(homedir2(), ".reasonix", "config.json");
4568
+ return join6(homedir3(), ".reasonix", "config.json");
3961
4569
  }
3962
4570
  function readConfig(path = defaultConfigPath()) {
3963
4571
  try {
3964
- const raw = readFileSync7(path, "utf8");
4572
+ const raw = readFileSync8(path, "utf8");
3965
4573
  const parsed = JSON.parse(raw);
3966
4574
  if (parsed && typeof parsed === "object") return parsed;
3967
4575
  } catch {
@@ -3969,8 +4577,8 @@ function readConfig(path = defaultConfigPath()) {
3969
4577
  return {};
3970
4578
  }
3971
4579
  function writeConfig(cfg, path = defaultConfigPath()) {
3972
- mkdirSync3(dirname4(path), { recursive: true });
3973
- writeFileSync3(path, JSON.stringify(cfg, null, 2), "utf8");
4580
+ mkdirSync4(dirname4(path), { recursive: true });
4581
+ writeFileSync4(path, JSON.stringify(cfg, null, 2), "utf8");
3974
4582
  try {
3975
4583
  chmodSync2(path, 384);
3976
4584
  } catch {
@@ -3996,7 +4604,7 @@ function redactKey(key) {
3996
4604
  }
3997
4605
 
3998
4606
  // src/index.ts
3999
- var VERSION = "0.4.17";
4607
+ var VERSION = "0.4.20";
4000
4608
  export {
4001
4609
  AppendOnlyLog,
4002
4610
  CODE_SYSTEM_PROMPT,
@@ -4005,16 +4613,21 @@ export {
4005
4613
  DeepSeekClient,
4006
4614
  ImmutablePrefix,
4007
4615
  MCP_PROTOCOL_VERSION,
4616
+ MEMORY_INDEX_FILE,
4617
+ MEMORY_INDEX_MAX_CHARS,
4008
4618
  McpClient,
4619
+ MemoryStore,
4009
4620
  NeedsConfirmationError,
4010
4621
  PROJECT_MEMORY_FILE,
4011
4622
  PROJECT_MEMORY_MAX_CHARS,
4623
+ PlanProposedError,
4012
4624
  SessionStats,
4013
4625
  SseTransport,
4014
4626
  StdioTransport,
4015
4627
  StormBreaker,
4016
4628
  ToolCallRepair,
4017
4629
  ToolRegistry,
4630
+ USER_MEMORY_DIR,
4018
4631
  Usage,
4019
4632
  VERSION,
4020
4633
  VolatileScratch,
@@ -4023,7 +4636,9 @@ export {
4023
4636
  appendSessionMessage,
4024
4637
  applyEditBlock,
4025
4638
  applyEditBlocks,
4639
+ applyMemoryStack,
4026
4640
  applyProjectMemory,
4641
+ applyUserMemory,
4027
4642
  bridgeMcpTools,
4028
4643
  claudeEquivalentCost,
4029
4644
  codeSystemPrompt,
@@ -4061,21 +4676,28 @@ export {
4061
4676
  parseMcpSpec,
4062
4677
  parseMojeekResults,
4063
4678
  parseTranscript,
4679
+ prepareSpawn,
4680
+ projectHash,
4681
+ quoteForCmdExe,
4064
4682
  readConfig,
4065
4683
  readProjectMemory,
4066
4684
  readTranscript,
4067
4685
  recordFromLoopEvent,
4068
4686
  redactKey,
4069
4687
  registerFilesystemTools,
4688
+ registerMemoryTools,
4689
+ registerPlanTool,
4070
4690
  registerShellTools,
4071
4691
  registerWebTools,
4072
4692
  renderMarkdown as renderDiffMarkdown,
4073
4693
  renderSummaryTable as renderDiffSummary,
4074
4694
  repairTruncatedJson,
4075
4695
  replayFromFile,
4696
+ resolveExecutable,
4076
4697
  restoreSnapshots,
4077
4698
  runBranches,
4078
4699
  runCommand,
4700
+ sanitizeMemoryName,
4079
4701
  sanitizeName as sanitizeSessionName,
4080
4702
  saveApiKey,
4081
4703
  scavengeToolCalls,