reasonix 0.0.5 → 0.0.6

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/README.md CHANGED
@@ -73,11 +73,18 @@ Measured on live DeepSeek API:
73
73
  ### CLI
74
74
 
75
75
  ```bash
76
- npx reasonix chat # just chat everything else is inside
76
+ npx reasonix chat # auto-saves to session 'default'; resumes next time
77
+ npx reasonix chat --session work # use a different named session
78
+ npx reasonix chat --no-session # ephemeral — nothing persisted
77
79
  npx reasonix run "ask anything" # one-shot, streams to stdout
78
80
  npx reasonix stats session.jsonl # read back a saved transcript
79
81
  ```
80
82
 
83
+ Sessions live as JSONL under `~/.reasonix/sessions/<name>.jsonl` — every
84
+ turn's message log is appended atomically, so killing the CLI never loses
85
+ context. Inside the TUI: `/sessions` to list, `/forget` to delete the
86
+ current one.
87
+
81
88
  ### Inside the chat — slash commands
82
89
 
83
90
  A command strip runs under the input box so you don't have to memorize
package/dist/cli/index.js CHANGED
@@ -783,6 +783,93 @@ function signature2(call) {
783
783
  return `${call.function?.name ?? ""}::${call.function?.arguments ?? ""}`;
784
784
  }
785
785
 
786
+ // src/session.ts
787
+ import {
788
+ appendFileSync,
789
+ chmodSync,
790
+ existsSync,
791
+ mkdirSync,
792
+ readFileSync,
793
+ readdirSync,
794
+ statSync,
795
+ unlinkSync
796
+ } from "fs";
797
+ import { homedir } from "os";
798
+ import { dirname, join } from "path";
799
+ function sessionsDir() {
800
+ return join(homedir(), ".reasonix", "sessions");
801
+ }
802
+ function sessionPath(name) {
803
+ return join(sessionsDir(), `${sanitizeName(name)}.jsonl`);
804
+ }
805
+ function sanitizeName(name) {
806
+ const cleaned = name.replace(/[^\w\-\u4e00-\u9fa5]/g, "_").slice(0, 64);
807
+ return cleaned || "default";
808
+ }
809
+ function loadSessionMessages(name) {
810
+ const path = sessionPath(name);
811
+ if (!existsSync(path)) return [];
812
+ try {
813
+ const raw = readFileSync(path, "utf8");
814
+ const out = [];
815
+ for (const line of raw.split(/\r?\n/)) {
816
+ const trimmed = line.trim();
817
+ if (!trimmed) continue;
818
+ try {
819
+ const msg = JSON.parse(trimmed);
820
+ if (msg && typeof msg === "object" && "role" in msg) out.push(msg);
821
+ } catch {
822
+ }
823
+ }
824
+ return out;
825
+ } catch {
826
+ return [];
827
+ }
828
+ }
829
+ function appendSessionMessage(name, message) {
830
+ const path = sessionPath(name);
831
+ mkdirSync(dirname(path), { recursive: true });
832
+ appendFileSync(path, `${JSON.stringify(message)}
833
+ `, "utf8");
834
+ try {
835
+ chmodSync(path, 384);
836
+ } catch {
837
+ }
838
+ }
839
+ function listSessions() {
840
+ const dir = sessionsDir();
841
+ if (!existsSync(dir)) return [];
842
+ try {
843
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
844
+ return files.map((file) => {
845
+ const path = join(dir, file);
846
+ const stat = statSync(path);
847
+ const name = file.replace(/\.jsonl$/, "");
848
+ const messageCount = countLines(path);
849
+ return { name, path, size: stat.size, messageCount, mtime: stat.mtime };
850
+ }).sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
851
+ } catch {
852
+ return [];
853
+ }
854
+ }
855
+ function deleteSession(name) {
856
+ const path = sessionPath(name);
857
+ try {
858
+ unlinkSync(path);
859
+ return true;
860
+ } catch {
861
+ return false;
862
+ }
863
+ }
864
+ function countLines(path) {
865
+ try {
866
+ const raw = readFileSync(path, "utf8");
867
+ return raw.split(/\r?\n/).filter((l) => l.trim()).length;
868
+ } catch {
869
+ return 0;
870
+ }
871
+ }
872
+
786
873
  // src/telemetry.ts
787
874
  var DEEPSEEK_PRICING = {
788
875
  "deepseek-chat": { inputCacheHit: 0.07, inputCacheMiss: 0.27, output: 1.1 },
@@ -939,6 +1026,9 @@ var CacheFirstLoop = class {
939
1026
  harvestOptions;
940
1027
  branchEnabled;
941
1028
  branchOptions;
1029
+ sessionName;
1030
+ /** Number of messages that were pre-loaded from the session file. */
1031
+ resumedMessageCount;
942
1032
  _turn = 0;
943
1033
  _streamPreference;
944
1034
  constructor(opts) {
@@ -962,6 +1052,23 @@ var CacheFirstLoop = class {
962
1052
  this.stream = this.branchEnabled ? false : this._streamPreference;
963
1053
  const allowedNames = /* @__PURE__ */ new Set([...this.prefix.toolSpecs.map((s) => s.function.name)]);
964
1054
  this.repair = new ToolCallRepair({ allowedToolNames: allowedNames });
1055
+ this.sessionName = opts.session ?? null;
1056
+ if (this.sessionName) {
1057
+ const prior = loadSessionMessages(this.sessionName);
1058
+ for (const msg of prior) this.log.append(msg);
1059
+ this.resumedMessageCount = prior.length;
1060
+ } else {
1061
+ this.resumedMessageCount = 0;
1062
+ }
1063
+ }
1064
+ appendAndPersist(message) {
1065
+ this.log.append(message);
1066
+ if (this.sessionName) {
1067
+ try {
1068
+ appendSessionMessage(this.sessionName, message);
1069
+ } catch {
1070
+ }
1071
+ }
965
1072
  }
966
1073
  /**
967
1074
  * Reconfigure model/harvest/branch/stream mid-session. The loop's log,
@@ -1149,7 +1256,7 @@ var CacheFirstLoop = class {
1149
1256
  }
1150
1257
  const turnStats = this.stats.record(this._turn, this.model, usage ?? new Usage());
1151
1258
  if (pendingUser !== null) {
1152
- this.log.append({ role: "user", content: pendingUser });
1259
+ this.appendAndPersist({ role: "user", content: pendingUser });
1153
1260
  pendingUser = null;
1154
1261
  }
1155
1262
  this.scratch.reasoning = reasoningContent || null;
@@ -1158,7 +1265,7 @@ var CacheFirstLoop = class {
1158
1265
  toolCalls,
1159
1266
  reasoningContent || null
1160
1267
  );
1161
- this.log.append(this.assistantMessage(assistantContent, repairedCalls));
1268
+ this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
1162
1269
  yield {
1163
1270
  turn: this._turn,
1164
1271
  role: "assistant_final",
@@ -1176,7 +1283,7 @@ var CacheFirstLoop = class {
1176
1283
  const name = call.function?.name ?? "";
1177
1284
  const args = call.function?.arguments ?? "{}";
1178
1285
  const result = await this.tools.dispatch(name, args);
1179
- this.log.append({
1286
+ this.appendAndPersist({
1180
1287
  role: "tool",
1181
1288
  tool_call_id: call.id ?? "",
1182
1289
  name,
@@ -1212,12 +1319,12 @@ function summarizeBranch(chosen, samples) {
1212
1319
  }
1213
1320
 
1214
1321
  // src/env.ts
1215
- import { readFileSync } from "fs";
1322
+ import { readFileSync as readFileSync2 } from "fs";
1216
1323
  import { resolve } from "path";
1217
1324
  function loadDotenv(path = ".env") {
1218
1325
  let raw;
1219
1326
  try {
1220
- raw = readFileSync(resolve(process.cwd(), path), "utf8");
1327
+ raw = readFileSync2(resolve(process.cwd(), path), "utf8");
1221
1328
  } catch {
1222
1329
  return;
1223
1330
  }
@@ -1236,15 +1343,15 @@ function loadDotenv(path = ".env") {
1236
1343
  }
1237
1344
 
1238
1345
  // src/config.ts
1239
- import { chmodSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
1240
- import { homedir } from "os";
1241
- import { dirname, join } from "path";
1346
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "fs";
1347
+ import { homedir as homedir2 } from "os";
1348
+ import { dirname as dirname2, join as join2 } from "path";
1242
1349
  function defaultConfigPath() {
1243
- return join(homedir(), ".reasonix", "config.json");
1350
+ return join2(homedir2(), ".reasonix", "config.json");
1244
1351
  }
1245
1352
  function readConfig(path = defaultConfigPath()) {
1246
1353
  try {
1247
- const raw = readFileSync2(path, "utf8");
1354
+ const raw = readFileSync3(path, "utf8");
1248
1355
  const parsed = JSON.parse(raw);
1249
1356
  if (parsed && typeof parsed === "object") return parsed;
1250
1357
  } catch {
@@ -1252,10 +1359,10 @@ function readConfig(path = defaultConfigPath()) {
1252
1359
  return {};
1253
1360
  }
1254
1361
  function writeConfig(cfg, path = defaultConfigPath()) {
1255
- mkdirSync(dirname(path), { recursive: true });
1362
+ mkdirSync2(dirname2(path), { recursive: true });
1256
1363
  writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
1257
1364
  try {
1258
- chmodSync(path, 384);
1365
+ chmodSync2(path, 384);
1259
1366
  } catch {
1260
1367
  }
1261
1368
  }
@@ -1629,15 +1736,51 @@ function handleSlash(cmd, args, loop) {
1629
1736
  " /model <id> deepseek-chat or deepseek-reasoner",
1630
1737
  " /harvest [on|off] Pillar 2: structured plan-state extraction",
1631
1738
  " /branch <N|off> run N parallel samples (N>=2), pick most confident",
1632
- " /clear clear displayed history (log is kept)",
1739
+ " /sessions list saved sessions (current is marked with \u25B8)",
1740
+ " /forget delete the current session from disk",
1741
+ " /clear clear displayed history (log + session kept)",
1633
1742
  " /exit quit",
1634
1743
  "",
1635
1744
  "Presets:",
1636
1745
  " fast deepseek-chat no harvest no branch ~1\xA2/100turns \u2190 default",
1637
1746
  " smart reasoner harvest ~10x cost, slower",
1638
- " max reasoner harvest branch 3 ~30x cost, slowest"
1747
+ " max reasoner harvest branch 3 ~30x cost, slowest",
1748
+ "",
1749
+ "Sessions (auto-enabled by default, named 'default'):",
1750
+ " reasonix chat --session <name> use a different named session",
1751
+ " reasonix chat --no-session disable persistence for this run"
1639
1752
  ].join("\n")
1640
1753
  };
1754
+ case "sessions": {
1755
+ const items = listSessions();
1756
+ if (items.length === 0) {
1757
+ return {
1758
+ info: "no saved sessions yet \u2014 chat normally and your messages will be saved automatically"
1759
+ };
1760
+ }
1761
+ const lines = ["Saved sessions:"];
1762
+ for (const s of items) {
1763
+ const sizeKb = (s.size / 1024).toFixed(1);
1764
+ const when = s.mtime.toISOString().replace("T", " ").slice(0, 16);
1765
+ const marker = s.name === loop.sessionName ? "\u25B8" : " ";
1766
+ lines.push(
1767
+ ` ${marker} ${s.name.padEnd(22)} ${String(s.messageCount).padStart(5)} msgs ${sizeKb.padStart(7)} KB ${when}`
1768
+ );
1769
+ }
1770
+ lines.push("");
1771
+ lines.push("Resume with: reasonix chat --session <name>");
1772
+ return { info: lines.join("\n") };
1773
+ }
1774
+ case "forget": {
1775
+ if (!loop.sessionName) {
1776
+ return { info: "not in a session \u2014 nothing to forget" };
1777
+ }
1778
+ const name = loop.sessionName;
1779
+ const ok = deleteSession(name);
1780
+ return {
1781
+ info: ok ? `\u25B8 deleted session "${name}" \u2014 current screen still shows the conversation, but next launch starts fresh` : `could not delete session "${name}" (already gone?)`
1782
+ };
1783
+ }
1641
1784
  case "status": {
1642
1785
  const branchBudget = loop.branchOptions.budget ?? 1;
1643
1786
  return {
@@ -1697,7 +1840,7 @@ function handleSlash(cmd, args, loop) {
1697
1840
 
1698
1841
  // src/cli/ui/App.tsx
1699
1842
  var FLUSH_INTERVAL_MS = 60;
1700
- function App({ model, system, transcript, harvest: harvest2, branch }) {
1843
+ function App({ model, system, transcript, harvest: harvest2, branch, session }) {
1701
1844
  const { exit } = useApp();
1702
1845
  const [historical, setHistorical] = useState2([]);
1703
1846
  const [streaming, setStreaming] = useState2(null);
@@ -1724,10 +1867,43 @@ function App({ model, system, transcript, harvest: harvest2, branch }) {
1724
1867
  if (loopRef.current) return loopRef.current;
1725
1868
  const client = new DeepSeekClient();
1726
1869
  const prefix = new ImmutablePrefix({ system });
1727
- const l = new CacheFirstLoop({ client, prefix, model, harvest: harvest2, branch });
1870
+ const l = new CacheFirstLoop({ client, prefix, model, harvest: harvest2, branch, session });
1728
1871
  loopRef.current = l;
1729
1872
  return l;
1730
- }, [model, system, harvest2, branch]);
1873
+ }, [model, system, harvest2, branch, session]);
1874
+ const sessionBannerShown = useRef(false);
1875
+ useEffect2(() => {
1876
+ if (sessionBannerShown.current) return;
1877
+ sessionBannerShown.current = true;
1878
+ if (!session) {
1879
+ setHistorical((prev) => [
1880
+ ...prev,
1881
+ {
1882
+ id: `sys-session-${Date.now()}`,
1883
+ role: "info",
1884
+ text: "\u25B8 ephemeral chat (no session persistence) \u2014 drop --no-session to enable"
1885
+ }
1886
+ ]);
1887
+ } else if (loop.resumedMessageCount > 0) {
1888
+ setHistorical((prev) => [
1889
+ ...prev,
1890
+ {
1891
+ id: `sys-resume-${Date.now()}`,
1892
+ role: "info",
1893
+ text: `\u25B8 resumed session "${session}" with ${loop.resumedMessageCount} prior messages \xB7 /forget to start over \xB7 /sessions to list`
1894
+ }
1895
+ ]);
1896
+ } else {
1897
+ setHistorical((prev) => [
1898
+ ...prev,
1899
+ {
1900
+ id: `sys-newsession-${Date.now()}`,
1901
+ role: "info",
1902
+ text: `\u25B8 session "${session}" (new) \u2014 auto-saved as you chat \xB7 /forget to delete \xB7 /sessions to list`
1903
+ }
1904
+ ]);
1905
+ }
1906
+ }, [session, loop]);
1731
1907
  const prefixHash = loop.prefix.fingerprint;
1732
1908
  const writeTranscript = useCallback((ev) => {
1733
1909
  transcriptRef.current?.write(
@@ -1873,7 +2049,7 @@ function App({ model, system, transcript, harvest: harvest2, branch }) {
1873
2049
  ), /* @__PURE__ */ React5.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React5.createElement(EventRow, { key: item.id, event: item })), streaming ? /* @__PURE__ */ React5.createElement(Box5, { marginY: 1 }, /* @__PURE__ */ React5.createElement(EventRow, { event: streaming })) : null, /* @__PURE__ */ React5.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React5.createElement(CommandStrip, null));
1874
2050
  }
1875
2051
  function CommandStrip() {
1876
- return /* @__PURE__ */ React5.createElement(Box5, { paddingX: 2 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /model \xB7 /harvest \xB7 /branch \xB7 /clear \xB7 /exit"));
2052
+ return /* @__PURE__ */ React5.createElement(Box5, { paddingX: 2 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /sessions \xB7 /model \xB7 /harvest \xB7 /branch \xB7 /clear \xB7 /exit"));
1877
2053
  }
1878
2054
  function describeRepair(repair) {
1879
2055
  const parts = [];
@@ -1944,7 +2120,8 @@ function Root({ initialKey, ...appProps }) {
1944
2120
  system: appProps.system,
1945
2121
  transcript: appProps.transcript,
1946
2122
  harvest: appProps.harvest,
1947
- branch: appProps.branch
2123
+ branch: appProps.branch,
2124
+ session: appProps.session
1948
2125
  }
1949
2126
  );
1950
2127
  }
@@ -2017,13 +2194,13 @@ async function runCommand(opts) {
2017
2194
  }
2018
2195
 
2019
2196
  // src/cli/commands/stats.ts
2020
- import { existsSync, readFileSync as readFileSync3 } from "fs";
2197
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
2021
2198
  function statsCommand(opts) {
2022
- if (!existsSync(opts.transcript)) {
2199
+ if (!existsSync2(opts.transcript)) {
2023
2200
  console.error(`no such transcript: ${opts.transcript}`);
2024
2201
  process.exit(1);
2025
2202
  }
2026
- const lines = readFileSync3(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
2203
+ const lines = readFileSync4(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
2027
2204
  let assistantTurns = 0;
2028
2205
  let toolCalls = 0;
2029
2206
  let lastTurn = 0;
@@ -2058,13 +2235,25 @@ program.command("chat").description("Interactive Ink TUI with live cache/cost pa
2058
2235
  "--branch <n>",
2059
2236
  "Self-consistency: run N parallel samples per turn and pick the most confident (disables streaming; enables harvest)",
2060
2237
  (v) => Number.parseInt(v, 10)
2061
- ).action(async (opts) => {
2238
+ ).option(
2239
+ "--session <name>",
2240
+ "Use a named session (default: 'default'). Resume the same session next time."
2241
+ ).option("--no-session", "Disable session persistence for this run (ephemeral chat)").action(async (opts) => {
2242
+ let session;
2243
+ if (opts.session === false) {
2244
+ session = void 0;
2245
+ } else if (typeof opts.session === "string" && opts.session.length > 0) {
2246
+ session = opts.session;
2247
+ } else {
2248
+ session = "default";
2249
+ }
2062
2250
  await chatCommand({
2063
2251
  model: opts.model,
2064
2252
  system: opts.system,
2065
2253
  transcript: opts.transcript,
2066
2254
  harvest: !!opts.harvest,
2067
- branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0
2255
+ branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0,
2256
+ session
2068
2257
  });
2069
2258
  });
2070
2259
  program.command("run <task>").description("Run a single task non-interactively, streaming output.").option("-m, --model <id>", "DeepSeek model id", "deepseek-chat").option("-s, --system <prompt>", "System prompt", DEFAULT_SYSTEM).action(async (task, opts) => {