@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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- 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
|
+
}
|