@cruxy/cli 1.8.0 → 1.8.1
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/cli/commands/run.js +57 -20
- package/dist/session/index.js +3 -3
- package/dist/session/list.js +109 -23
- package/dist/session/log.js +28 -9
- package/dist/session/replay.js +71 -21
- package/dist/session/resume.js +74 -12
- package/dist/session/types.js +45 -0
- package/package.json +1 -1
package/dist/cli/commands/run.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { LimitsClient } from "@cruxy/sdk";
|
|
4
4
|
import { LimitsCache } from "../../limits/index.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
import { SessionLog, listSessions,
|
|
6
|
+
import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, shortId, } from "../../session/index.js";
|
|
7
7
|
/** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
|
|
8
8
|
const SIDEBAR_SESSIONS = 10;
|
|
9
9
|
import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
|
|
@@ -56,6 +56,56 @@ export async function executeRun(promptParts, opts) {
|
|
|
56
56
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
57
57
|
const invokedAs = opts.commandName ?? "cruxy run";
|
|
58
58
|
const interactive = prompt === "";
|
|
59
|
+
// ORDER OF COMPLAINTS (#172 item 3). Three things can refuse this run before
|
|
60
|
+
// it starts, and they are checked most-specific first:
|
|
61
|
+
//
|
|
62
|
+
// 1. a bad `--root` (the workspace cannot even be built)
|
|
63
|
+
// 2. a bad `--resume <id>` (the session named does not exist)
|
|
64
|
+
// 3. no prompt, and no TTY (nothing to do, no way to be asked)
|
|
65
|
+
//
|
|
66
|
+
// The terminal check USED to come first, which meant
|
|
67
|
+
// `cruxy --resume no-such-id < /dev/null` was told to add a message — advice
|
|
68
|
+
// that, followed, only surfaces the real error one run later. `program.ts`
|
|
69
|
+
// deliberately routes `--resume` past the guidance banner so a bad id fails
|
|
70
|
+
// loud; the guard sitting in front of it undid exactly that.
|
|
71
|
+
//
|
|
72
|
+
// The tradeoff this reordering accepts: `--root` errors now precede the
|
|
73
|
+
// terminal message too, because id validation needs `primaryRoot` and so the
|
|
74
|
+
// workspace has to be built first. That is the right way round — a
|
|
75
|
+
// non-existent root is a more specific complaint than "needs a terminal", and
|
|
76
|
+
// it was already the first thing to fail on every OTHER path.
|
|
77
|
+
//
|
|
78
|
+
// The guard is NOT simply moved below resume resolution instead. That would
|
|
79
|
+
// put `loadConfig` and the missing-key check in front of it, so a keyless
|
|
80
|
+
// non-TTY run would be told to authenticate rather than that it has nothing
|
|
81
|
+
// to do — trading one misleading diagnostic for another.
|
|
82
|
+
// Declared workspace roots (C.26). This is the ONE place `run` reads the
|
|
83
|
+
// process working directory — the invocation directory is the base for
|
|
84
|
+
// resolving `--root` paths and the sole root when none are declared. Every
|
|
85
|
+
// subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
|
|
86
|
+
// re-reads the invocation directory, so nothing can silently split-brain to
|
|
87
|
+
// a different dir than the roots the tools see (the guard test pins this).
|
|
88
|
+
const invocationCwd = process.cwd();
|
|
89
|
+
// No --root → a trivial single-root workspace (byte-identical to pre-C.26).
|
|
90
|
+
// --root builds a genuine multi-root Workspace and fails fast HERE — a
|
|
91
|
+
// missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
|
|
92
|
+
// onboarding or the session starts, never a half-built session.
|
|
93
|
+
const workspace = opts.root.length
|
|
94
|
+
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
95
|
+
: sessionWorkspace(invocationCwd);
|
|
96
|
+
const primaryRoot = workspace.primary().absPath;
|
|
97
|
+
// VALIDATION ONLY — deliberately not the load. `resolveSessionId` answers
|
|
98
|
+
// "does this id name a session?" from one meta line per file; the replay that
|
|
99
|
+
// actually restores the conversation happens far below, once we know the run
|
|
100
|
+
// is going to proceed. A valid `--resume <id> < /dev/null` with no message is
|
|
101
|
+
// still going to be turned away by the guard just below, and it should not
|
|
102
|
+
// have paid for a full `replaySession` to get there.
|
|
103
|
+
//
|
|
104
|
+
// Bare `--resume` (no id) cannot be validated here and is not: the picker
|
|
105
|
+
// needs a terminal, so it stays below the guard where it has always been.
|
|
106
|
+
const resumeTarget = typeof opts.resume === "string" && opts.resume !== ""
|
|
107
|
+
? resolveSessionId(primaryRoot, opts.resume)
|
|
108
|
+
: null;
|
|
59
109
|
// No prompt and stdin isn't a terminal: there's no way to read input and
|
|
60
110
|
// nothing to do — fail fast instead of hanging on a line that never comes.
|
|
61
111
|
//
|
|
@@ -78,21 +128,6 @@ export async function executeRun(promptParts, opts) {
|
|
|
78
128
|
const loaded = loadConfig();
|
|
79
129
|
const { config, sources } = loaded;
|
|
80
130
|
let apiKey = resolveApiKey(config.model.provider);
|
|
81
|
-
// Declared workspace roots (C.26). This is the ONE place `run` reads the
|
|
82
|
-
// process working directory — the invocation directory is the base for
|
|
83
|
-
// resolving `--root` paths and the sole root when none are declared. Every
|
|
84
|
-
// subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
|
|
85
|
-
// re-reads the invocation directory, so nothing can silently split-brain to
|
|
86
|
-
// a different dir than the roots the tools see (the guard test pins this).
|
|
87
|
-
const invocationCwd = process.cwd();
|
|
88
|
-
// No --root → a trivial single-root workspace (byte-identical to pre-C.26).
|
|
89
|
-
// --root builds a genuine multi-root Workspace and fails fast HERE — a
|
|
90
|
-
// missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
|
|
91
|
-
// onboarding or the session starts, never a half-built session.
|
|
92
|
-
const workspace = opts.root.length
|
|
93
|
-
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
94
|
-
: sessionWorkspace(invocationCwd);
|
|
95
|
-
const primaryRoot = workspace.primary().absPath;
|
|
96
131
|
logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
|
|
97
132
|
logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
|
|
98
133
|
// Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
|
|
@@ -147,10 +182,12 @@ export async function executeRun(promptParts, opts) {
|
|
|
147
182
|
// a repo moved would be worse than saying so plainly.
|
|
148
183
|
let resumed = null;
|
|
149
184
|
if (opts.resume !== undefined && opts.resume !== false) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
// `resumeTarget` is the id already validated above the guard; loading it is
|
|
186
|
+
// all that is left. Bare `--resume` resolves here, where the TTY it needs is
|
|
187
|
+
// guaranteed — the guard above turned away every run that lacked one.
|
|
188
|
+
resumed = resumeTarget
|
|
189
|
+
? loadResume(resumeTarget, primaryRoot)
|
|
190
|
+
: await resumePicker(primaryRoot, { logger });
|
|
154
191
|
if (resumed) {
|
|
155
192
|
for (const warning of resumed.warnings)
|
|
156
193
|
logger.warn(warning);
|
package/dist/session/index.js
CHANGED
|
@@ -16,6 +16,6 @@ export { SessionLog } from "./log.js";
|
|
|
16
16
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
17
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
18
|
export { redactMessages } from "./redact.js";
|
|
19
|
-
export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
|
|
20
|
-
export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
|
-
export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
19
|
+
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
20
|
+
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
|
+
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/list.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { projectDir, SESSION_FILE_EXT } from "./paths.js";
|
|
4
|
+
import { readMeta } from "./replay.js";
|
|
4
5
|
import { MessageSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
5
6
|
/**
|
|
6
7
|
* Listing sessions for the resume picker and the TUI sidebar (P2). Both read
|
|
@@ -22,10 +23,18 @@ function toTitle(text) {
|
|
|
22
23
|
* Summarize one session file: its meta, its first user prompt (the title) and
|
|
23
24
|
* how many user turns it holds.
|
|
24
25
|
*
|
|
25
|
-
* This reads the whole file, which is the honest cost of counting turns
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* This reads the whole file, which is the honest cost of counting turns — a
|
|
27
|
+
* partial read cannot produce a correct count, and reporting a wrong one is
|
|
28
|
+
* worse than paying for a right one.
|
|
29
|
+
*
|
|
30
|
+
* What changed: this used to claim the cost was "bounded in practice — the
|
|
31
|
+
* picker asks for ten". It was not. {@link listSessions} summarized EVERY file
|
|
32
|
+
* and sliced afterwards, so the limit bounded the rows and not the work; a
|
|
33
|
+
* project of 200 sessions paid 200 full parses to show 10. The limit now bounds
|
|
34
|
+
* the work (see {@link sessionRefsByRecency}), which is what finally makes that
|
|
35
|
+
* sentence true. A sidecar index is still the answer if per-file cost ever
|
|
36
|
+
* stops being acceptable — but the ordering fix had to come first, because an
|
|
37
|
+
* index would have made the same mistake faster.
|
|
29
38
|
*/
|
|
30
39
|
export function summarizeSession(file) {
|
|
31
40
|
let raw;
|
|
@@ -89,10 +98,18 @@ export function summarizeSession(file) {
|
|
|
89
98
|
return { ...meta, title: title ?? UNTITLED, turns };
|
|
90
99
|
}
|
|
91
100
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
101
|
+
* Session files in `cwd`'s project, most-recently-modified first.
|
|
102
|
+
*
|
|
103
|
+
* `readdir` plus one `stat` each — NO file is opened. This is the ordering step
|
|
104
|
+
* that lets every caller below bound its own work: recency is knowable from the
|
|
105
|
+
* directory alone, so the expensive per-file read only ever has to happen for
|
|
106
|
+
* the files a caller is actually going to show.
|
|
107
|
+
*
|
|
108
|
+
* Missing directory → empty list (not an error: no sessions yet is normal). A
|
|
109
|
+
* file that vanishes between the `readdir` and the `stat` is skipped rather
|
|
110
|
+
* than throwing — listing races an active session by definition.
|
|
94
111
|
*/
|
|
95
|
-
|
|
112
|
+
function sessionRefsByRecency(cwd) {
|
|
96
113
|
const dir = projectDir(cwd);
|
|
97
114
|
let names;
|
|
98
115
|
try {
|
|
@@ -101,37 +118,106 @@ export function listSessions(cwd, limit = Infinity) {
|
|
|
101
118
|
catch {
|
|
102
119
|
return [];
|
|
103
120
|
}
|
|
104
|
-
const
|
|
121
|
+
const files = [];
|
|
105
122
|
for (const name of names) {
|
|
106
123
|
if (!name.endsWith(SESSION_FILE_EXT))
|
|
107
124
|
continue;
|
|
108
|
-
const
|
|
125
|
+
const file = path.join(dir, name);
|
|
126
|
+
try {
|
|
127
|
+
files.push({ file, mtimeMs: statSync(file).mtimeMs });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue; // deleted mid-listing
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Newest first, with the path as a tiebreak so two files written in the same
|
|
134
|
+
// millisecond list in a stable order rather than whatever `readdir` returned.
|
|
135
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Every session recorded for `cwd`'s project, most-recently-updated first,
|
|
140
|
+
* capped at `limit`.
|
|
141
|
+
*
|
|
142
|
+
* `limit` bounds the WORK, not just the rows. Files are ordered by mtime first
|
|
143
|
+
* (cheap — see {@link sessionRefsByRecency}) and summarized one at a time until
|
|
144
|
+
* `limit` valid summaries exist, so the picker asking for ten reads ten files
|
|
145
|
+
* and not two hundred.
|
|
146
|
+
*
|
|
147
|
+
* OVER-FETCH AND REFILL is why this is a loop and not a `slice`. A file with no
|
|
148
|
+
* readable meta summarizes to `null` and is skipped — that tolerance is the
|
|
149
|
+
* point of the format and `tree.test.ts` pins it. Taking the ten most recent
|
|
150
|
+
* files and summarizing those would let one junk file among them silently
|
|
151
|
+
* return nine rows; walking until ten SUMMARIES exist costs one wasted parse
|
|
152
|
+
* per junk file and always returns ten when ten are there.
|
|
153
|
+
*/
|
|
154
|
+
export function listSessions(cwd, limit = Infinity) {
|
|
155
|
+
const summaries = [];
|
|
156
|
+
for (const { file } of sessionRefsByRecency(cwd)) {
|
|
157
|
+
if (summaries.length >= limit)
|
|
158
|
+
break;
|
|
159
|
+
const summary = summarizeSession(file);
|
|
109
160
|
if (summary)
|
|
110
161
|
summaries.push(summary);
|
|
111
162
|
}
|
|
112
|
-
summaries
|
|
113
|
-
|
|
163
|
+
return summaries;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Every session's IDENTITY for `cwd`'s project, most-recently-updated first.
|
|
167
|
+
*
|
|
168
|
+
* One meta line per file, via {@link readMeta} — no turn counting, no title.
|
|
169
|
+
* This is the index `--resume <id>` resolves against, and it exists because
|
|
170
|
+
* resolution never needed the expensive half: on a 200-session project it costs
|
|
171
|
+
* ~10ms against ~120ms for the equivalent {@link listSessions}, and the resume
|
|
172
|
+
* path used to build the expensive one two or three times over.
|
|
173
|
+
*
|
|
174
|
+
* `meta` stays authoritative for the id, deliberately — the FILENAME also
|
|
175
|
+
* carries it, and matching on that would be cheaper still, but it would make a
|
|
176
|
+
* file with no readable meta resolvable and then unloadable. A session that
|
|
177
|
+
* cannot be summarized must not be offerable either.
|
|
178
|
+
*/
|
|
179
|
+
export function listSessionRefs(cwd) {
|
|
180
|
+
const refs = [];
|
|
181
|
+
for (const { file, mtimeMs } of sessionRefsByRecency(cwd)) {
|
|
182
|
+
const meta = readMeta(file);
|
|
183
|
+
if (meta)
|
|
184
|
+
refs.push({ sessionId: meta.sessionId, file, mtimeMs });
|
|
185
|
+
}
|
|
186
|
+
return refs;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Match an id (or id prefix) against an already-built index.
|
|
190
|
+
*
|
|
191
|
+
* Pure, and separate from the reading, so ONE index can answer both "is this
|
|
192
|
+
* ambiguous?" and "which one is it?" — the two questions the resume path asks
|
|
193
|
+
* back to back, and used to rebuild the whole listing to answer separately.
|
|
194
|
+
*
|
|
195
|
+
* An exact id always wins outright: `abc` names the session called `abc` even
|
|
196
|
+
* when `abcdef` also exists, so a full id is never ambiguous against something
|
|
197
|
+
* that merely starts the same way.
|
|
198
|
+
*/
|
|
199
|
+
export function matchSessionRefs(refs, id) {
|
|
200
|
+
const exact = refs.find((r) => r.sessionId === id);
|
|
201
|
+
if (exact)
|
|
202
|
+
return [exact];
|
|
203
|
+
return refs.filter((r) => r.sessionId.startsWith(id));
|
|
114
204
|
}
|
|
115
205
|
/**
|
|
116
206
|
* Find one session by id (or unambiguous id prefix) within `cwd`'s project.
|
|
117
|
-
* Returns null when nothing matches
|
|
118
|
-
* loudly to fail.
|
|
207
|
+
* Returns null when nothing matches or the prefix is ambiguous; throws nothing
|
|
208
|
+
* — the caller decides how loudly to fail.
|
|
119
209
|
*
|
|
120
210
|
* Prefix matching exists because the ids are UUIDs and nobody is going to type
|
|
121
211
|
* one; the picker and the sidebar both show a short form.
|
|
212
|
+
*
|
|
213
|
+
* Builds its own index. `resolveSessionId` in `resume.ts` is the path that
|
|
214
|
+
* builds ONE and asks both questions of it; prefer that when you need both.
|
|
122
215
|
*/
|
|
123
216
|
export function findSession(cwd, id) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
if (exact)
|
|
127
|
-
return exact;
|
|
128
|
-
const matches = all.filter((s) => s.sessionId.startsWith(id));
|
|
129
|
-
return matches.length === 1 ? matches[0] : null;
|
|
217
|
+
const matches = matchSessionRefs(listSessionRefs(cwd), id);
|
|
218
|
+
return matches.length === 1 ? summarizeSession(matches[0].file) : null;
|
|
130
219
|
}
|
|
131
220
|
/** Whether an id prefix matches more than one session (an ambiguous resume). */
|
|
132
221
|
export function isAmbiguous(cwd, id) {
|
|
133
|
-
|
|
134
|
-
if (all.some((s) => s.sessionId === id))
|
|
135
|
-
return false;
|
|
136
|
-
return all.filter((s) => s.sessionId.startsWith(id)).length > 1;
|
|
222
|
+
return matchSessionRefs(listSessionRefs(cwd), id).length > 1;
|
|
137
223
|
}
|
package/dist/session/log.js
CHANGED
|
@@ -15,23 +15,42 @@ export class SessionLog {
|
|
|
15
15
|
this.currentRunId = opts.currentRunId;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Open (or create) a session log.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* Open (or create) a session log.
|
|
19
|
+
*
|
|
20
|
+
* A NEW file gets its `meta` line. Reopening an existing one (a `--resume`)
|
|
21
|
+
* still does NOT get a second `meta` — that ruling is unchanged and is the
|
|
22
|
+
* reason this method has always branched: `meta` describes where and when the
|
|
23
|
+
* conversation began, and a second copy written from wherever it was resumed
|
|
24
|
+
* would make "the session's directory" ambiguous. Replay takes the first
|
|
25
|
+
* `meta`, so a duplicate could only ever mislead a later reader.
|
|
26
|
+
*
|
|
27
|
+
* What a reopen gets instead is a `resumed` event (#172 item 2). It carries
|
|
28
|
+
* the directory this run is in, which is the fact that used to vanish: a
|
|
29
|
+
* session begun in one directory and continued in another recorded nothing
|
|
30
|
+
* about the second, so `cwdMismatchWarning` told the user at the time and the
|
|
31
|
+
* file forgot immediately. `meta` stays singular and authoritative; the
|
|
32
|
+
* reopen is a separate kind, and no reader can confuse the two.
|
|
24
33
|
*
|
|
25
34
|
* Returns `null` when a new log cannot be created at all — the caller then
|
|
26
35
|
* runs without persistence rather than failing, exactly as a corrupt usage
|
|
27
|
-
* store is survivable. A reopen never returns null:
|
|
28
|
-
*
|
|
36
|
+
* store is survivable. A reopen still never returns null: the `resumed` write
|
|
37
|
+
* is now the first append, and a write problem there warns and goes inert
|
|
38
|
+
* exactly as any other failed append does. A session that cannot record its
|
|
39
|
+
* own reopen is not a session worth refusing to continue.
|
|
29
40
|
*/
|
|
30
41
|
static open(opts) {
|
|
31
42
|
const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
|
|
32
43
|
const log = new SessionLog(file, opts);
|
|
33
|
-
if (hasContent(file))
|
|
44
|
+
if (hasContent(file)) {
|
|
45
|
+
log.write({
|
|
46
|
+
kind: "resumed",
|
|
47
|
+
at: new Date().toISOString(),
|
|
48
|
+
cwd: opts.cwd,
|
|
49
|
+
roots: opts.roots ?? [],
|
|
50
|
+
cliVersion: APP_VERSION,
|
|
51
|
+
});
|
|
34
52
|
return log;
|
|
53
|
+
}
|
|
35
54
|
const ok = log.write({
|
|
36
55
|
kind: "meta",
|
|
37
56
|
version: SESSION_FILE_VERSION,
|
package/dist/session/replay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
3
|
import { redactMessages } from "./redact.js";
|
|
4
|
-
import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
|
+
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
7
7
|
* resume (P2).
|
|
@@ -11,8 +11,7 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
11
11
|
* the array the model last saw, including the synthetic compaction summaries.
|
|
12
12
|
* Nothing is re-derived or re-summarised on the way back in.
|
|
13
13
|
*
|
|
14
|
-
* TOLERANCE IS THE POINT. A line that will not parse is SKIPPED
|
|
15
|
-
* never fatal:
|
|
14
|
+
* TOLERANCE IS THE POINT. A line that will not parse is SKIPPED, never fatal:
|
|
16
15
|
* - a torn final line from a crash mid-append would otherwise cost the whole
|
|
17
16
|
* conversation (this is the failure temp-then-rename buys off at write time;
|
|
18
17
|
* an append-only log buys it off here instead);
|
|
@@ -20,8 +19,25 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
20
19
|
* heard of is skipped rather than rejected, which together with the
|
|
21
20
|
* `.passthrough()` schemas is what keeps two CLI versions able to share one
|
|
22
21
|
* home directory.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* THE TWO ARE COUNTED APART (#172 item 2), because they are not the same fact
|
|
24
|
+
* and the caller says something different about each. A damaged line means
|
|
25
|
+
* content was LOST — "the restored history may be incomplete" is warranted. An
|
|
26
|
+
* unknown kind means content is not visible HERE while the file is perfectly
|
|
27
|
+
* intact.
|
|
28
|
+
*
|
|
29
|
+
* Conflating them was survivable only while unknown kinds were rare. The
|
|
30
|
+
* `resumed` event is written on EVERY reopen, so an older build sharing a home
|
|
31
|
+
* directory would have announced possible history loss on every single resume —
|
|
32
|
+
* a forward-compatibility mechanism producing a corruption warning as routine
|
|
33
|
+
* output. `SessionState.skipped` now counts only damage;
|
|
34
|
+
* `SessionState.unknownEvents` counts the rest.
|
|
35
|
+
*
|
|
36
|
+
* This fixes readers from this build forward. An ALREADY-SHIPPED cruxy has the
|
|
37
|
+
* old reader and will still say "unreadable lines" when it meets a `resumed`
|
|
38
|
+
* event; nothing here can reach it. That is the cost of the split landing with
|
|
39
|
+
* the event rather than before it, and it is bounded — it misreports, it does
|
|
40
|
+
* not lose anything.
|
|
25
41
|
*/
|
|
26
42
|
/**
|
|
27
43
|
* The one cast in this module, isolated and explained.
|
|
@@ -36,20 +52,37 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
36
52
|
function asMessages(validated) {
|
|
37
53
|
return validated;
|
|
38
54
|
}
|
|
39
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Classify one line.
|
|
57
|
+
*
|
|
58
|
+
* The distinction that matters is inside the failure case. A line that is valid
|
|
59
|
+
* JSON, is an object, and names a `kind` this build has never heard of is a
|
|
60
|
+
* NEWER cruxy's event — the file is intact and the forward-compatibility rule is
|
|
61
|
+
* working as designed. Anything else that fails is damage.
|
|
62
|
+
*
|
|
63
|
+
* A known kind with a payload that does not validate counts as DAMAGE, not as
|
|
64
|
+
* unknown: this build understands that kind, so failing to parse it means the
|
|
65
|
+
* line is wrong rather than merely new.
|
|
66
|
+
*/
|
|
40
67
|
function parseLine(line) {
|
|
41
68
|
const trimmed = line.trim();
|
|
42
69
|
if (trimmed === "")
|
|
43
|
-
return
|
|
70
|
+
return { outcome: "blank" };
|
|
44
71
|
let raw;
|
|
45
72
|
try {
|
|
46
73
|
raw = JSON.parse(trimmed);
|
|
47
74
|
}
|
|
48
75
|
catch {
|
|
49
|
-
return
|
|
76
|
+
return { outcome: "damaged" }; // torn mid-write
|
|
50
77
|
}
|
|
51
78
|
const parsed = SessionEventSchema.safeParse(raw);
|
|
52
|
-
|
|
79
|
+
if (parsed.success)
|
|
80
|
+
return { outcome: "event", event: parsed.data };
|
|
81
|
+
const kind = raw?.kind;
|
|
82
|
+
if (typeof kind === "string" && !KNOWN_EVENT_KINDS.has(kind)) {
|
|
83
|
+
return { outcome: "unknown" };
|
|
84
|
+
}
|
|
85
|
+
return { outcome: "damaged" };
|
|
53
86
|
}
|
|
54
87
|
/**
|
|
55
88
|
* Fold events into {@link SessionState}. Exported separately from file reading
|
|
@@ -60,7 +93,7 @@ function parseLine(line) {
|
|
|
60
93
|
* which directory the history refers to, and resuming a conversation whose
|
|
61
94
|
* origin is unknown is precisely what ruling 4 forbids.
|
|
62
95
|
*/
|
|
63
|
-
export function foldEvents(events,
|
|
96
|
+
export function foldEvents(events, counts = {}) {
|
|
64
97
|
const metaEvent = events.find((e) => e.kind === "meta");
|
|
65
98
|
if (!metaEvent) {
|
|
66
99
|
throw new Error("session log has no readable meta line");
|
|
@@ -76,6 +109,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
76
109
|
let planMode = false;
|
|
77
110
|
let mode = null;
|
|
78
111
|
let redactions = 0;
|
|
112
|
+
const resumes = [];
|
|
79
113
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
80
114
|
for (const event of events) {
|
|
81
115
|
switch (event.kind) {
|
|
@@ -116,6 +150,12 @@ export function foldEvents(events, skipped = 0) {
|
|
|
116
150
|
messages = redactMessages(messages).messages;
|
|
117
151
|
redactions++;
|
|
118
152
|
break;
|
|
153
|
+
case "resumed":
|
|
154
|
+
// Recorded, never folded into anything the model sees. A reopen is a
|
|
155
|
+
// fact ABOUT the conversation, not a turn in it — the whole reason it
|
|
156
|
+
// could be added without touching `meta` or the message array.
|
|
157
|
+
resumes.push({ at: event.at, cwd: event.cwd });
|
|
158
|
+
break;
|
|
119
159
|
case "meta":
|
|
120
160
|
break;
|
|
121
161
|
}
|
|
@@ -128,7 +168,9 @@ export function foldEvents(events, skipped = 0) {
|
|
|
128
168
|
// Auto-approve reads false there, which is right: it was not a thing that
|
|
129
169
|
// could be on, so nothing is being inferred.
|
|
130
170
|
mode: mode ?? modeFromFlags(planMode, false),
|
|
131
|
-
skipped,
|
|
171
|
+
skipped: counts.skipped ?? 0,
|
|
172
|
+
unknownEvents: counts.unknownEvents ?? 0,
|
|
173
|
+
resumes,
|
|
132
174
|
redactions,
|
|
133
175
|
};
|
|
134
176
|
}
|
|
@@ -137,21 +179,29 @@ export function readEvents(file) {
|
|
|
137
179
|
const raw = readFileSync(file, "utf8");
|
|
138
180
|
const events = [];
|
|
139
181
|
let skipped = 0;
|
|
182
|
+
let unknownEvents = 0;
|
|
140
183
|
for (const line of raw.split("\n")) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
const parsed = parseLine(line);
|
|
185
|
+
switch (parsed.outcome) {
|
|
186
|
+
case "event":
|
|
187
|
+
events.push(parsed.event);
|
|
188
|
+
break;
|
|
189
|
+
case "unknown":
|
|
190
|
+
unknownEvents++;
|
|
191
|
+
break;
|
|
192
|
+
case "damaged":
|
|
193
|
+
skipped++;
|
|
194
|
+
break;
|
|
195
|
+
case "blank":
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
148
198
|
}
|
|
149
|
-
return { events, skipped };
|
|
199
|
+
return { events, skipped, unknownEvents };
|
|
150
200
|
}
|
|
151
201
|
/** Read a session file and fold it into resumable state. */
|
|
152
202
|
export function replaySession(file) {
|
|
153
|
-
const { events, skipped } = readEvents(file);
|
|
154
|
-
return foldEvents(events, skipped);
|
|
203
|
+
const { events, skipped, unknownEvents } = readEvents(file);
|
|
204
|
+
return foldEvents(events, { skipped, unknownEvents });
|
|
155
205
|
}
|
|
156
206
|
/**
|
|
157
207
|
* Read ONLY the meta line — enough to list a session without folding its whole
|
package/dist/session/resume.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { selectList } from "../components/index.js";
|
|
2
2
|
import { usageError } from "../errors/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
4
4
|
import { replaySession } from "./replay.js";
|
|
5
5
|
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
6
|
export const PICKER_LIMIT = 10;
|
|
@@ -47,6 +47,28 @@ export function cwdMismatchWarning(state, cwd) {
|
|
|
47
47
|
return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
|
|
48
48
|
`its history refers to files and paths from the original directory`);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Directories this session has run in BEFORE, other than the one we are
|
|
52
|
+
* resuming into now (#172 item 2). Returns a warning to print, or null.
|
|
53
|
+
*
|
|
54
|
+
* Distinct from {@link cwdMismatchWarning}, which compares where the
|
|
55
|
+
* conversation BEGAN against where it is being resumed. This compares where it
|
|
56
|
+
* has since RUN. The two disagree in exactly the case that motivated the
|
|
57
|
+
* `resumed` event: begin in `/a`, resume in `/b`, then resume in `/a` again —
|
|
58
|
+
* `meta.cwd` matches, so the mismatch check is silent, and yet the history now
|
|
59
|
+
* contains a whole stretch of work done against a different tree.
|
|
60
|
+
*
|
|
61
|
+
* Nothing could say this before, because nothing recorded it. Sessions written
|
|
62
|
+
* before the event simply have no `resumes` and are silent here — absence is
|
|
63
|
+
* "not recorded", never "did not happen".
|
|
64
|
+
*/
|
|
65
|
+
export function priorDirectoriesWarning(state, cwd) {
|
|
66
|
+
const others = [...new Set(state.resumes.map((r) => r.cwd))].filter((dir) => dir !== cwd && dir !== state.meta.cwd);
|
|
67
|
+
if (others.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
return (`this session has also run in ${others.join(", ")} — ` +
|
|
70
|
+
`part of its history refers to files and paths from ${others.length === 1 ? "that directory" : "those directories"}`);
|
|
71
|
+
}
|
|
50
72
|
/**
|
|
51
73
|
* Load one session and collect its warnings. Throws a usage error when the
|
|
52
74
|
* file cannot be replayed at all (no meta line) — an unreadable session is
|
|
@@ -67,10 +89,23 @@ export function loadResume(session, cwd) {
|
|
|
67
89
|
const mismatch = cwdMismatchWarning(state, cwd);
|
|
68
90
|
if (mismatch)
|
|
69
91
|
warnings.push(mismatch);
|
|
92
|
+
const elsewhere = priorDirectoriesWarning(state, cwd);
|
|
93
|
+
if (elsewhere)
|
|
94
|
+
warnings.push(elsewhere);
|
|
70
95
|
if (state.skipped > 0) {
|
|
71
96
|
warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
|
|
72
97
|
`the restored history may be incomplete`);
|
|
73
98
|
}
|
|
99
|
+
if (state.unknownEvents > 0) {
|
|
100
|
+
// Deliberately NOT the sentence above. The file is intact; this build is
|
|
101
|
+
// simply older than whatever wrote those lines, and saying "unreadable"
|
|
102
|
+
// about a healthy log would send the user looking for damage that is not
|
|
103
|
+
// there. What IS true is that some of what the session recorded cannot be
|
|
104
|
+
// shown here.
|
|
105
|
+
warnings.push(`${state.unknownEvents} event${state.unknownEvents === 1 ? "" : "s"} in this session ` +
|
|
106
|
+
`${state.unknownEvents === 1 ? "was" : "were"} written by a newer cruxy and ${state.unknownEvents === 1 ? "is" : "are"} not shown — ` +
|
|
107
|
+
`the conversation itself is complete; upgrade to see the rest`);
|
|
108
|
+
}
|
|
74
109
|
if (state.redactions > 0) {
|
|
75
110
|
// Said on resume because the alternative is a user finding `[redacted …]`
|
|
76
111
|
// in a transcript and not knowing whether cruxy did it or the model wrote
|
|
@@ -82,27 +117,54 @@ export function loadResume(session, cwd) {
|
|
|
82
117
|
return { session, state, warnings };
|
|
83
118
|
}
|
|
84
119
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
120
|
+
* VALIDATE `--resume <id>` — which session does this name? — without loading it.
|
|
121
|
+
*
|
|
122
|
+
* Split from the loading half for two reasons, one of them ordering:
|
|
123
|
+
*
|
|
124
|
+
* 1. `executeRun` has to answer "is this a real id?" BEFORE the non-TTY guard,
|
|
125
|
+
* so `cruxy --resume no-such-id < /dev/null` complains about the id rather
|
|
126
|
+
* than about the terminal. It must not have to pay a full `replaySession`
|
|
127
|
+
* to find that out — a VALID id in that same position is still going to be
|
|
128
|
+
* told it needs a terminal, and reading a whole conversation only to throw
|
|
129
|
+
* it away is exactly the cost this split avoids.
|
|
130
|
+
* 2. Both questions — ambiguous? which one? — are now asked of ONE index. This
|
|
131
|
+
* used to build the full listing up to three times (`isAmbiguous`, then
|
|
132
|
+
* `findSession`, then a third time to name the collisions), each one a
|
|
133
|
+
* complete read and parse of every session file in the project.
|
|
134
|
+
*
|
|
135
|
+
* Fails loud on an unknown or ambiguous id rather than silently starting a new
|
|
136
|
+
* session — the user named something specific.
|
|
137
|
+
*
|
|
138
|
+
* The one file that matched IS summarized, so the caller still gets the title
|
|
139
|
+
* and turn count the resume line prints. That is one read, not N.
|
|
87
140
|
*/
|
|
88
|
-
export function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.map((s) => shortId(s.sessionId));
|
|
141
|
+
export function resolveSessionId(cwd, id) {
|
|
142
|
+
const matches = matchSessionRefs(listSessionRefs(cwd), id);
|
|
143
|
+
if (matches.length > 1) {
|
|
144
|
+
const names = matches.map((m) => shortId(m.sessionId)).join(", ");
|
|
93
145
|
throw usageError(`\`${id}\` matches more than one session`, [
|
|
94
|
-
`did you mean one of: ${
|
|
146
|
+
`did you mean one of: ${names}?`,
|
|
95
147
|
"run `cruxy --resume` to pick from a list",
|
|
96
148
|
]);
|
|
97
149
|
}
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
150
|
+
const summary = matches.length === 1 ? summarizeSession(matches[0].file) : null;
|
|
151
|
+
if (!summary) {
|
|
100
152
|
throw usageError(`no session \`${id}\` in this project`, [
|
|
101
153
|
"run `cruxy --resume` to pick from recent sessions",
|
|
102
154
|
"sessions are per-directory; check you are in the right one",
|
|
103
155
|
]);
|
|
104
156
|
}
|
|
105
|
-
return
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve `--resume <id>` all the way to restorable state: validate, then load.
|
|
161
|
+
*
|
|
162
|
+
* `executeRun` calls the two halves separately, so validation can precede the
|
|
163
|
+
* non-TTY guard; this composition is what everything else (and the tests) use
|
|
164
|
+
* where there is no ordering constraint to respect.
|
|
165
|
+
*/
|
|
166
|
+
export function resumeById(cwd, id) {
|
|
167
|
+
return loadResume(resolveSessionId(cwd, id), cwd);
|
|
106
168
|
}
|
|
107
169
|
/**
|
|
108
170
|
* Bare `--resume`: pick from the most recent sessions, or start a new one.
|
package/dist/session/types.js
CHANGED
|
@@ -248,6 +248,41 @@ export const RedactEventSchema = z
|
|
|
248
248
|
count: z.number().int().nonnegative().default(0),
|
|
249
249
|
})
|
|
250
250
|
.passthrough();
|
|
251
|
+
/**
|
|
252
|
+
* A session was REOPENED (#172 item 2) — `--resume` found this log and
|
|
253
|
+
* continued it.
|
|
254
|
+
*
|
|
255
|
+
* `meta` is written once and never again, on purpose: it records where and when
|
|
256
|
+
* the conversation BEGAN, and a second copy written from wherever it was
|
|
257
|
+
* resumed would make "the session's directory" ambiguous. That ruling stands.
|
|
258
|
+
* Its consequence was that a resume left no trace at all — a session started in
|
|
259
|
+
* one directory and continued in another looked, from the file, as though it
|
|
260
|
+
* had only ever run in the first.
|
|
261
|
+
*
|
|
262
|
+
* This event closes that without touching `meta`'s authority. `meta.cwd` is
|
|
263
|
+
* still where the conversation began; `resumed.cwd` is somewhere it has since
|
|
264
|
+
* run. A reader can tell the two apart because they are different kinds.
|
|
265
|
+
*
|
|
266
|
+
* SCOPE, stated because the obvious next step is deliberately NOT taken here:
|
|
267
|
+
* this makes a cross-directory resume AUDITABLE, not DISCOVERABLE. The file
|
|
268
|
+
* still lives under `projectKey(meta.cwd)`, and `listSessions` for the other
|
|
269
|
+
* directory does one `readdir` of its own project dir and will never see it.
|
|
270
|
+
* Surfacing it there needs a pointer written into the second directory, or a
|
|
271
|
+
* scan across every project — a separate decision with a real cost, not a
|
|
272
|
+
* side effect of recording the fact.
|
|
273
|
+
*/
|
|
274
|
+
export const ResumedEventSchema = z
|
|
275
|
+
.object({
|
|
276
|
+
kind: z.literal("resumed"),
|
|
277
|
+
at: z.string(),
|
|
278
|
+
/** The primary root the session was resumed INTO. */
|
|
279
|
+
cwd: z.string(),
|
|
280
|
+
/** Roots declared on the resuming run — multi-root can differ per run. */
|
|
281
|
+
roots: z.array(RootRefSchema).default([]),
|
|
282
|
+
/** The build that reopened it; a session can outlive several. */
|
|
283
|
+
cliVersion: z.string().optional(),
|
|
284
|
+
})
|
|
285
|
+
.passthrough();
|
|
251
286
|
/** Every event, discriminated on `kind`. */
|
|
252
287
|
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
253
288
|
SessionMetaSchema,
|
|
@@ -258,4 +293,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
258
293
|
SessionModeEventSchema,
|
|
259
294
|
UsageEventSchema,
|
|
260
295
|
RedactEventSchema,
|
|
296
|
+
ResumedEventSchema,
|
|
261
297
|
]);
|
|
298
|
+
/**
|
|
299
|
+
* Every `kind` this build understands, derived from the union itself so the two
|
|
300
|
+
* cannot drift.
|
|
301
|
+
*
|
|
302
|
+
* This exists so the reader can tell "a line I do not understand" from "a line
|
|
303
|
+
* that is damaged" — see `replay.ts`. Those are different facts about a file and
|
|
304
|
+
* they used to be counted as one.
|
|
305
|
+
*/
|
|
306
|
+
export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
|