@cruxy/cli 1.2.1 → 1.4.0

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.
Files changed (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,88 @@
1
+ import { COMPACTION_MARKER } from "../agent/prompts.js";
2
+ /**
3
+ * Rendering a conversation to Markdown for `/export` (P6 track 4).
4
+ *
5
+ * WHAT THIS CAN AND CANNOT CONTAIN, said in the file itself rather than left for
6
+ * someone to discover. The source is `Session.messages` — the LIVE history, the
7
+ * thing the model can currently see. That is the right source for "export this
8
+ * conversation", and it is lossy in one specific way: anything a compaction
9
+ * folded away is present only as the summary that replaced it. The full
10
+ * transcript survives in the session log, which is append-only precisely so a
11
+ * compaction never destroys what was said.
12
+ *
13
+ * So an export whose history contains a compaction marker says so at the top.
14
+ * The alternative — exporting silently and letting the reader assume the file is
15
+ * the whole conversation — is the failure mode worth spending three lines on.
16
+ *
17
+ * Tool payloads are included in FULL. An export is a file, not a screen: the
18
+ * caller asked for the conversation, and a truncated one is a transcript that
19
+ * cannot be searched for the thing you exported it to find.
20
+ */
21
+ /** Fence a payload without letting its own backticks break out of the block. */
22
+ function fence(body, lang = "") {
23
+ // Longest run of backticks in the body, so the fence always outlives it.
24
+ let longest = 0;
25
+ for (const match of body.matchAll(/`+/g)) {
26
+ longest = Math.max(longest, match[0].length);
27
+ }
28
+ const ticks = "`".repeat(Math.max(3, longest + 1));
29
+ return `${ticks}${lang}\n${body}\n${ticks}`;
30
+ }
31
+ /** One content block as Markdown. */
32
+ function renderBlock(block) {
33
+ switch (block.type) {
34
+ case "text":
35
+ return block.text;
36
+ case "tool_use":
37
+ return `**called \`${block.name}\`**\n\n${fence(JSON.stringify(block.input, null, 2), "json")}`;
38
+ case "tool_result":
39
+ return `**tool result${block.is_error ? " (error)" : ""}**\n\n${fence(block.content)}`;
40
+ }
41
+ }
42
+ /** One message as a titled Markdown section. */
43
+ function renderMessage(message) {
44
+ const isUser = message.role === "user";
45
+ if (typeof message.content === "string") {
46
+ const compacted = message.content.startsWith(COMPACTION_MARKER);
47
+ const heading = compacted ? "compaction" : isUser ? "you" : "cruxy";
48
+ return `## ${heading}\n\n${message.content}`;
49
+ }
50
+ // A `role: "user"` message with blocks is a tool RESULT, not something the
51
+ // human said. Titling it "you" would attribute grep output to the user.
52
+ const heading = message.content.some((b) => b.type === "tool_result")
53
+ ? "tools"
54
+ : isUser
55
+ ? "you"
56
+ : "cruxy";
57
+ return `## ${heading}\n\n${message.content.map(renderBlock).join("\n\n")}`;
58
+ }
59
+ /**
60
+ * Render a conversation to a Markdown document.
61
+ *
62
+ * Pure: no clock, no filesystem, no config. `exportedAt` is passed in for that
63
+ * reason — a renderer that read the clock could not be tested for its own
64
+ * output.
65
+ */
66
+ export function exportMarkdown(messages, meta) {
67
+ const compacted = messages.some((m) => typeof m.content === "string" && m.content.startsWith(COMPACTION_MARKER));
68
+ const header = [
69
+ `# cruxy session ${meta.sessionId.slice(0, 8)}`,
70
+ "",
71
+ `- exported: ${meta.exportedAt}`,
72
+ `- directory: ${meta.cwd}`,
73
+ `- model: ${meta.provider}${meta.model === undefined ? "" : `/${meta.model}`}`,
74
+ `- messages: ${messages.length}`,
75
+ ];
76
+ if (compacted) {
77
+ // The one thing a reader could otherwise get wrong about this file.
78
+ header.push("", "> This conversation was compacted: an earlier stretch of it was replaced by", "> a summary to stay within the context budget, and only that summary appears", "> below. The full transcript is preserved in the session log, which is", "> append-only — `cruxy --resume` lists sessions by id.");
79
+ }
80
+ if (messages.length === 0) {
81
+ return `${[...header, "", "_(no messages)_"].join("\n")}\n`;
82
+ }
83
+ return `${[...header, "", ...messages.map(renderMessage)].join("\n\n")}\n`;
84
+ }
85
+ /** The default filename for a session export: `cruxy-session-<short id>.md`. */
86
+ export function defaultExportName(sessionId) {
87
+ return `cruxy-session-${sessionId.slice(0, 8)}.md`;
88
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Session persistence (P2): an append-only JSONL event log per conversation,
3
+ * under `~/.cruxy/projects/<project>/<session-id>.jsonl`.
4
+ *
5
+ * The pieces, in the order they matter:
6
+ * - `types.ts` — the event shapes, `.passthrough()` throughout so a newer
7
+ * cruxy's fields never make an older one discard a session;
8
+ * - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
9
+ * - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
10
+ * - `list.ts` — what the picker and the TUI sidebar both read;
11
+ * - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
12
+ * - `paths.ts` — the layout, including the subtrees reserved for P3+.
13
+ */
14
+ export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, projectKey, projectsDir, reservedDir, sessionFile, } from "./paths.js";
15
+ export { SessionLog } from "./log.js";
16
+ export { defaultExportName, exportMarkdown, } from "./export.js";
17
+ export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
18
+ export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
19
+ export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
20
+ export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
@@ -0,0 +1,137 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { projectDir, SESSION_FILE_EXT } from "./paths.js";
4
+ import { MessageSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
5
+ /**
6
+ * Listing sessions for the resume picker and the TUI sidebar (P2). Both read
7
+ * this one function, so the two surfaces can never disagree about what exists
8
+ * or in what order.
9
+ */
10
+ /** How many characters of the first prompt name a session. */
11
+ const TITLE_MAX = 60;
12
+ /** A session with no user turn yet — opened, then abandoned. */
13
+ const UNTITLED = "(no messages)";
14
+ /** First line of `text`, squashed and trimmed to {@link TITLE_MAX}. */
15
+ function toTitle(text) {
16
+ const flat = text.replace(/\s+/g, " ").trim();
17
+ if (flat === "")
18
+ return UNTITLED;
19
+ return flat.length > TITLE_MAX ? `${flat.slice(0, TITLE_MAX - 1)}…` : flat;
20
+ }
21
+ /**
22
+ * Summarize one session file: its meta, its first user prompt (the title) and
23
+ * how many user turns it holds.
24
+ *
25
+ * This reads the whole file, which is the honest cost of counting turns. It is
26
+ * bounded in practice — the picker asks for ten — and a session file is text
27
+ * measured in tens of kilobytes. If it ever stops being cheap the fix is a
28
+ * sidecar index, not a partial read that reports a wrong count.
29
+ */
30
+ export function summarizeSession(file) {
31
+ let raw;
32
+ let updatedAt;
33
+ try {
34
+ raw = readFileSync(file, "utf8");
35
+ updatedAt = statSync(file).mtime.toISOString();
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ let meta = null;
41
+ let title = null;
42
+ let turns = 0;
43
+ for (const line of raw.split("\n")) {
44
+ if (line.trim() === "")
45
+ continue;
46
+ let parsedJson;
47
+ try {
48
+ parsedJson = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue; // torn line — skip, same tolerance as replay
52
+ }
53
+ if (meta === null) {
54
+ const m = SessionMetaSchema.safeParse(parsedJson);
55
+ if (m.success) {
56
+ meta = {
57
+ sessionId: m.data.sessionId,
58
+ file,
59
+ startedAt: m.data.startedAt,
60
+ updatedAt,
61
+ cwd: m.data.cwd,
62
+ title: UNTITLED,
63
+ turns: 0,
64
+ };
65
+ continue;
66
+ }
67
+ }
68
+ const event = SessionEventSchema.safeParse(parsedJson);
69
+ if (!event.success || event.data.kind !== "append")
70
+ continue;
71
+ for (const message of event.data.messages) {
72
+ const parsed = MessageSchema.safeParse(message);
73
+ if (!parsed.success)
74
+ continue;
75
+ // A user turn is a `role: "user"` message with STRING content. Tool
76
+ // results are also role "user" but carry blocks, so this counts what the
77
+ // human actually said and nothing else — the same shape test
78
+ // `Session.findCut` uses to find a real turn boundary.
79
+ if (parsed.data.role !== "user" ||
80
+ typeof parsed.data.content !== "string")
81
+ continue;
82
+ turns++;
83
+ if (title === null)
84
+ title = toTitle(parsed.data.content);
85
+ }
86
+ }
87
+ if (meta === null)
88
+ return null;
89
+ return { ...meta, title: title ?? UNTITLED, turns };
90
+ }
91
+ /**
92
+ * Every session recorded for `cwd`'s project, most-recently-updated first.
93
+ * Missing directory → empty list (not an error: no sessions yet is normal).
94
+ */
95
+ export function listSessions(cwd, limit = Infinity) {
96
+ const dir = projectDir(cwd);
97
+ let names;
98
+ try {
99
+ names = readdirSync(dir);
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ const summaries = [];
105
+ for (const name of names) {
106
+ if (!name.endsWith(SESSION_FILE_EXT))
107
+ continue;
108
+ const summary = summarizeSession(path.join(dir, name));
109
+ if (summary)
110
+ summaries.push(summary);
111
+ }
112
+ summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
113
+ return Number.isFinite(limit) ? summaries.slice(0, limit) : summaries;
114
+ }
115
+ /**
116
+ * Find one session by id (or unambiguous id prefix) within `cwd`'s project.
117
+ * Returns null when nothing matches; throws nothing — the caller decides how
118
+ * loudly to fail.
119
+ *
120
+ * Prefix matching exists because the ids are UUIDs and nobody is going to type
121
+ * one; the picker and the sidebar both show a short form.
122
+ */
123
+ export function findSession(cwd, id) {
124
+ const all = listSessions(cwd);
125
+ const exact = all.find((s) => s.sessionId === id);
126
+ if (exact)
127
+ return exact;
128
+ const matches = all.filter((s) => s.sessionId.startsWith(id));
129
+ return matches.length === 1 ? matches[0] : null;
130
+ }
131
+ /** Whether an id prefix matches more than one session (an ambiguous resume). */
132
+ export function isAmbiguous(cwd, id) {
133
+ const all = listSessions(cwd);
134
+ if (all.some((s) => s.sessionId === id))
135
+ return false;
136
+ return all.filter((s) => s.sessionId.startsWith(id)).length > 1;
137
+ }
@@ -0,0 +1,137 @@
1
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { APP_VERSION } from "../constants.js";
4
+ import { sessionFile } from "./paths.js";
5
+ import { SESSION_FILE_VERSION, } from "./types.js";
6
+ export class SessionLog {
7
+ file;
8
+ logger;
9
+ currentRunId;
10
+ /** Set once a write fails: the log goes inert rather than warning per turn. */
11
+ broken = false;
12
+ constructor(file, opts) {
13
+ this.file = file;
14
+ this.logger = opts.logger;
15
+ this.currentRunId = opts.currentRunId;
16
+ }
17
+ /**
18
+ * Open (or create) a session log. A NEW file gets its `meta` line; reopening
19
+ * an existing one (a `--resume`) does not — `meta` describes where and when
20
+ * the conversation began, and a second copy written from wherever it was
21
+ * resumed would make "the session's directory" ambiguous. Replay already
22
+ * takes the first `meta`, so a duplicate could only ever mislead a later
23
+ * reader, never help one.
24
+ *
25
+ * Returns `null` when a new log cannot be created at all — the caller then
26
+ * runs without persistence rather than failing, exactly as a corrupt usage
27
+ * store is survivable. A reopen never returns null: a write problem on an
28
+ * existing file surfaces on the first append, which warns and goes inert.
29
+ */
30
+ static open(opts) {
31
+ const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
32
+ const log = new SessionLog(file, opts);
33
+ if (hasContent(file))
34
+ return log;
35
+ const ok = log.write({
36
+ kind: "meta",
37
+ version: SESSION_FILE_VERSION,
38
+ sessionId: opts.sessionId,
39
+ startedAt: new Date().toISOString(),
40
+ cwd: opts.cwd,
41
+ roots: opts.roots ?? [],
42
+ cliVersion: APP_VERSION,
43
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
44
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
45
+ });
46
+ return ok ? log : null;
47
+ }
48
+ /** Messages appended to the history since the last event. */
49
+ append(messages) {
50
+ if (messages.length === 0)
51
+ return;
52
+ this.write({
53
+ kind: "append",
54
+ at: new Date().toISOString(),
55
+ ...this.runId(),
56
+ messages,
57
+ });
58
+ }
59
+ /** An older prefix of `replaced` messages was folded into `summary`. */
60
+ compaction(replaced, summary) {
61
+ this.write({
62
+ kind: "compaction",
63
+ at: new Date().toISOString(),
64
+ ...this.runId(),
65
+ replaced,
66
+ summary,
67
+ });
68
+ }
69
+ /** `/clear` — history dropped, session kept. */
70
+ clear() {
71
+ this.write({ kind: "clear", at: new Date().toISOString() });
72
+ }
73
+ /**
74
+ * The session mode changed (P5 track 3). Replaces the `plan-mode` event,
75
+ * which is still READ for sessions recorded before modes existed but is no
76
+ * longer written.
77
+ */
78
+ mode(mode) {
79
+ this.write({ kind: "mode", at: new Date().toISOString(), mode });
80
+ }
81
+ /**
82
+ * One turn's token usage. Copied here rather than referenced, because the
83
+ * usage store keeps only its newest 50 runs while sessions are kept
84
+ * indefinitely — see the note on `UsageEventSchema`.
85
+ */
86
+ usage(inputTokens, outputTokens) {
87
+ this.write({
88
+ kind: "usage",
89
+ at: new Date().toISOString(),
90
+ ...this.runId(),
91
+ inputTokens,
92
+ outputTokens,
93
+ });
94
+ }
95
+ runId() {
96
+ const id = this.currentRunId?.();
97
+ return id === undefined ? {} : { runId: id };
98
+ }
99
+ /**
100
+ * Append one event as a single line. Returns whether it landed. The first
101
+ * failure warns and latches `broken`, so a persistent problem (a full disk)
102
+ * produces one diagnostic rather than one per turn.
103
+ */
104
+ write(event) {
105
+ if (this.broken)
106
+ return false;
107
+ try {
108
+ mkdirSync(path.dirname(this.file), { recursive: true });
109
+ // `mode` applies only when the file is created — 0600 from the first
110
+ // line, matching credentials/usage/memory.
111
+ appendFileSync(this.file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
112
+ return true;
113
+ }
114
+ catch (err) {
115
+ this.broken = true;
116
+ this.logger?.warn(`session not being saved: ${err.message} (the conversation continues in memory)`);
117
+ return false;
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Whether `file` is an existing log with content — i.e. this is a reopen.
123
+ *
124
+ * `isFile()` matters: a DIRECTORY at this path reports a non-zero size, so a
125
+ * size check alone would read an unwritable path as a healthy existing log,
126
+ * skip the meta write, and hand back a recorder that silently fails on its
127
+ * first append instead of declining up front.
128
+ */
129
+ function hasContent(file) {
130
+ try {
131
+ const stat = statSync(file);
132
+ return stat.isFile() && stat.size > 0;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import { globalDir } from "../config/paths.js";
3
+ /**
4
+ * Where sessions live on disk (P2).
5
+ *
6
+ * ```
7
+ * ~/.cruxy/projects/<project>/
8
+ * <session-id>.jsonl ← the event log (P2)
9
+ * subagents/ ← reserved, P3+
10
+ * tool-results/ ← reserved, P3+
11
+ * file-history/ ← reserved, P3+
12
+ * memory/ ← reserved, P3+
13
+ * ```
14
+ *
15
+ * Everything resolves through {@link globalDir}, never a fresh `homedir()` —
16
+ * one accessor owns the location of `~/.cruxy` and the tests can point it
17
+ * somewhere else by pointing that.
18
+ *
19
+ * The reserved subdirectories are DECLARED here and not created: an empty
20
+ * directory tree is noise until something writes to it, but naming the paths
21
+ * now is what stops P3 from inventing a second, conflicting layout.
22
+ */
23
+ /** `~/.cruxy/projects` */
24
+ export const PROJECTS_DIR_NAME = "projects";
25
+ /** Session event-log extension. */
26
+ export const SESSION_FILE_EXT = ".jsonl";
27
+ /** Reserved subtrees — declared for P3+, deliberately not created by P2. */
28
+ export const RESERVED_SUBDIRS = [
29
+ "subagents",
30
+ "tool-results",
31
+ "file-history",
32
+ "memory",
33
+ ];
34
+ /**
35
+ * The directory key for a working directory: every run of non-alphanumerics
36
+ * collapses to a single `-`, with leading/trailing separators trimmed.
37
+ *
38
+ * Flattening the path rather than nesting it keeps one directory per project
39
+ * (so listing is a single readdir) and cannot escape the projects root the way
40
+ * a path fragment could. It is deliberately NOT reversible — the true `cwd` is
41
+ * recorded in each session's `meta` event, which is what resume validates
42
+ * against; the key is only an index.
43
+ *
44
+ * Two different directories CAN collide onto one key (`/a/b` and `/a-b`). That
45
+ * is why resume compares `meta.cwd`, not the key, before trusting a session.
46
+ */
47
+ export function projectKey(cwd) {
48
+ const key = path.resolve(cwd).replace(/[^a-zA-Z0-9]+/g, "-");
49
+ const trimmed = key.replace(/^-+/, "").replace(/-+$/, "");
50
+ // A path of nothing but separators (`/`) collapses to empty; give it a name
51
+ // rather than writing into the projects root itself.
52
+ return trimmed === "" ? "root" : trimmed;
53
+ }
54
+ /** `~/.cruxy/projects` */
55
+ export function projectsDir() {
56
+ return path.join(globalDir(), PROJECTS_DIR_NAME);
57
+ }
58
+ /**
59
+ * `~/.cruxy/projects/<project>` for a working directory. Multi-root sessions
60
+ * pass the PRIMARY root — the session belongs to one project even when it can
61
+ * read several, and `meta.roots` is what records the rest.
62
+ */
63
+ export function projectDir(cwd) {
64
+ return path.join(projectsDir(), projectKey(cwd));
65
+ }
66
+ /** `~/.cruxy/projects/<project>/<session-id>.jsonl` */
67
+ export function sessionFile(cwd, sessionId) {
68
+ return path.join(projectDir(cwd), `${sessionId}${SESSION_FILE_EXT}`);
69
+ }
70
+ /** A reserved subtree's path (P3+). Declared, not created. */
71
+ export function reservedDir(cwd, name) {
72
+ return path.join(projectDir(cwd), name);
73
+ }
@@ -0,0 +1,169 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { modeFromFlags } from "../agent/mode.js";
3
+ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
4
+ /**
5
+ * Replay: fold an append-only event log back into the state a session needs to
6
+ * resume (P2).
7
+ *
8
+ * The fold is total and order-dependent — `append` grows the array,
9
+ * `compaction` replaces its head, `clear` empties it — so the result is exactly
10
+ * the array the model last saw, including the synthetic compaction summaries.
11
+ * Nothing is re-derived or re-summarised on the way back in.
12
+ *
13
+ * TOLERANCE IS THE POINT. A line that will not parse is SKIPPED and counted,
14
+ * never fatal:
15
+ * - a torn final line from a crash mid-append would otherwise cost the whole
16
+ * conversation (this is the failure temp-then-rename buys off at write time;
17
+ * an append-only log buys it off here instead);
18
+ * - a line from a NEWER cruxy carrying an event kind this build has never
19
+ * heard of is skipped rather than rejected, which together with the
20
+ * `.passthrough()` schemas is what keeps two CLI versions able to share one
21
+ * home directory.
22
+ * `SessionState.skipped` carries the count so a caller can say so out loud
23
+ * rather than presenting a partial history as complete.
24
+ */
25
+ /**
26
+ * The one cast in this module, isolated and explained.
27
+ *
28
+ * `MessageSchema` is `.passthrough()`, so its inferred type carries an index
29
+ * signature that the SDK's `Message` interface does not declare. The values are
30
+ * structurally identical — the schema validates every field `Message` requires
31
+ * and preserves the rest — so this narrows a validated value to the nominal
32
+ * type the rest of the CLI speaks. It is sound precisely BECAUSE the schema ran
33
+ * first; do not move it above the parse.
34
+ */
35
+ function asMessages(validated) {
36
+ return validated;
37
+ }
38
+ /** Parse one line, or `null` when it is unusable (torn, or an unknown kind). */
39
+ function parseLine(line) {
40
+ const trimmed = line.trim();
41
+ if (trimmed === "")
42
+ return null;
43
+ let raw;
44
+ try {
45
+ raw = JSON.parse(trimmed);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ const parsed = SessionEventSchema.safeParse(raw);
51
+ return parsed.success ? parsed.data : null;
52
+ }
53
+ /**
54
+ * Fold events into {@link SessionState}. Exported separately from file reading
55
+ * so the fold is directly testable without a filesystem — the same split as
56
+ * `render/state.ts` (pure) versus the renderers.
57
+ *
58
+ * Throws only when there is no usable `meta` line: without it we do not know
59
+ * which directory the history refers to, and resuming a conversation whose
60
+ * origin is unknown is precisely what ruling 4 forbids.
61
+ */
62
+ export function foldEvents(events, skipped = 0) {
63
+ const metaEvent = events.find((e) => e.kind === "meta");
64
+ if (!metaEvent) {
65
+ throw new Error("session log has no readable meta line");
66
+ }
67
+ const meta = metaEvent;
68
+ let messages = [];
69
+ // Both are folded, and `mode` wins when present. A journal written before P5
70
+ // has only `plan-mode` events; one written after has only `mode` events; a
71
+ // session resumed by a P5 build and then continued has both, in that order.
72
+ // Last-writer-wins across the two would be wrong — the pre-P5 events are all
73
+ // earlier, so the newer vocabulary must take precedence rather than the newer
74
+ // timestamp.
75
+ let planMode = false;
76
+ let mode = null;
77
+ const usage = { input_tokens: 0, output_tokens: 0 };
78
+ for (const event of events) {
79
+ switch (event.kind) {
80
+ case "append":
81
+ messages = [...messages, ...asMessages(event.messages)];
82
+ break;
83
+ case "compaction":
84
+ // Exactly what `Session.runCompaction` did in memory: drop the head,
85
+ // splice the synthetic pair in front of what was kept.
86
+ messages = [
87
+ ...asMessages(event.summary),
88
+ ...messages.slice(event.replaced),
89
+ ];
90
+ break;
91
+ case "clear":
92
+ messages = [];
93
+ break;
94
+ case "plan-mode":
95
+ planMode = event.enabled;
96
+ break;
97
+ case "mode":
98
+ mode = event.mode;
99
+ break;
100
+ case "usage":
101
+ usage.input_tokens += event.inputTokens;
102
+ usage.output_tokens += event.outputTokens;
103
+ break;
104
+ case "meta":
105
+ break;
106
+ }
107
+ }
108
+ return {
109
+ meta,
110
+ messages,
111
+ usage,
112
+ // A pre-P5 journal's plan flag becomes the mode it was equivalent to.
113
+ // Auto-approve reads false there, which is right: it was not a thing that
114
+ // could be on, so nothing is being inferred.
115
+ mode: mode ?? modeFromFlags(planMode, false),
116
+ skipped,
117
+ };
118
+ }
119
+ /** Read and parse a session file into its events, counting unusable lines. */
120
+ export function readEvents(file) {
121
+ const raw = readFileSync(file, "utf8");
122
+ const events = [];
123
+ let skipped = 0;
124
+ for (const line of raw.split("\n")) {
125
+ if (line.trim() === "")
126
+ continue;
127
+ const event = parseLine(line);
128
+ if (event)
129
+ events.push(event);
130
+ else
131
+ skipped++;
132
+ }
133
+ return { events, skipped };
134
+ }
135
+ /** Read a session file and fold it into resumable state. */
136
+ export function replaySession(file) {
137
+ const { events, skipped } = readEvents(file);
138
+ return foldEvents(events, skipped);
139
+ }
140
+ /**
141
+ * Read ONLY the meta line — enough to list a session without folding its whole
142
+ * history. Returns null when the file has no usable meta.
143
+ */
144
+ export function readMeta(file) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(file, "utf8");
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ for (const line of raw.split("\n")) {
153
+ if (line.trim() === "")
154
+ continue;
155
+ try {
156
+ const parsed = SessionMetaSchema.safeParse(JSON.parse(line));
157
+ if (parsed.success)
158
+ return parsed.data;
159
+ }
160
+ catch {
161
+ // keep scanning: a torn first line must not hide a good meta below it
162
+ }
163
+ // Only the meta line is expected first; if the first usable line is some
164
+ // other event the file is malformed for our purposes, but keep scanning
165
+ // rather than giving up — the cost is bounded and the payoff is a session
166
+ // that lists instead of vanishing.
167
+ }
168
+ return null;
169
+ }