@cruxy/cli 1.2.1 → 1.3.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/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 +374 -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 +755 -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 +27 -3
- package/dist/components/select.js +3 -3
- 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 +198 -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 +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -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 +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { fit } from "./layout.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rendering a test run (P3): the shared formatter behind
|
|
4
|
+
* {@link StreamRenderer.testResult}, so every medium reports the same run the
|
|
5
|
+
* same way.
|
|
6
|
+
*
|
|
7
|
+
* The honesty rules are `testing/`'s, carried through rather than re-derived:
|
|
8
|
+
* `passed` comes from the exit code and nothing else, `total` exists only when
|
|
9
|
+
* a parser confidently extracted it, and `failures` can be empty even for a
|
|
10
|
+
* failing run. So this NEVER computes a count it was not given — no
|
|
11
|
+
* `total - failures.length` passed-count, no "1 test failed" inferred from a
|
|
12
|
+
* non-zero exit. When the numbers are absent it reports the outcome and the
|
|
13
|
+
* duration, and says that it could not recognize the details.
|
|
14
|
+
*/
|
|
15
|
+
/** Failures listed before the rest are summarized. */
|
|
16
|
+
export const MAX_LISTED_FAILURES = 5;
|
|
17
|
+
/** `4.2s`, `380ms` — a measured duration, never estimated. */
|
|
18
|
+
export function formatDuration(ms) {
|
|
19
|
+
if (ms < 1000)
|
|
20
|
+
return `${Math.round(ms)}ms`;
|
|
21
|
+
const seconds = ms / 1000;
|
|
22
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The count clause, or "" when nothing countable was extracted.
|
|
26
|
+
*
|
|
27
|
+
* `total` is the only number a parser is allowed to produce, and `failures` is
|
|
28
|
+
* a best-effort list — so "N of M failed" is claimable only when both are
|
|
29
|
+
* present, and a bare failure list is reported as "N failures" without implying
|
|
30
|
+
* it is exhaustive.
|
|
31
|
+
*/
|
|
32
|
+
function counts(report, t) {
|
|
33
|
+
const failed = report.failures.length;
|
|
34
|
+
if (report.total !== undefined) {
|
|
35
|
+
if (report.passed)
|
|
36
|
+
return t.muted(`${report.total} tests`);
|
|
37
|
+
if (failed > 0)
|
|
38
|
+
return t.danger(`${failed} of ${report.total} failed`);
|
|
39
|
+
return t.muted(`${report.total} tests`);
|
|
40
|
+
}
|
|
41
|
+
if (!report.passed && failed > 0)
|
|
42
|
+
return t.danger(`${failed} failure${failed === 1 ? "" : "s"}`);
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
/** One failure: its name, then `file:line` when the parser recognized them. */
|
|
46
|
+
function failureLine(failure, t) {
|
|
47
|
+
const where = failure.file === undefined
|
|
48
|
+
? ""
|
|
49
|
+
: ` ${t.muted(failure.file + (failure.line === undefined ? "" : `:${failure.line}`))}`;
|
|
50
|
+
return ` ${t.danger(t.glyph.failure)} ${failure.name}${where}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A test run as committed lines: a headline, then the recognized failures.
|
|
54
|
+
*
|
|
55
|
+
* This is committed output, not live state — a run is an event that happened,
|
|
56
|
+
* so every medium keeps it rather than redrawing it.
|
|
57
|
+
*/
|
|
58
|
+
export function testResultLines(report, t, width = Infinity) {
|
|
59
|
+
const mark = report.passed
|
|
60
|
+
? t.success(t.glyph.success)
|
|
61
|
+
: t.danger(t.glyph.failure);
|
|
62
|
+
const headline = report.passed
|
|
63
|
+
? t.success("tests passed")
|
|
64
|
+
: t.danger("tests failed");
|
|
65
|
+
const parts = [
|
|
66
|
+
counts(report, t),
|
|
67
|
+
t.muted(formatDuration(report.durationMs)),
|
|
68
|
+
t.muted(report.command),
|
|
69
|
+
].filter((p) => p !== "");
|
|
70
|
+
const lines = [
|
|
71
|
+
`${mark} ${headline} ${t.muted(t.glyph.sep)} ${parts.join(t.muted(` ${t.glyph.sep} `))}`,
|
|
72
|
+
];
|
|
73
|
+
for (const failure of report.failures.slice(0, MAX_LISTED_FAILURES))
|
|
74
|
+
lines.push(failureLine(failure, t));
|
|
75
|
+
if (report.failures.length > MAX_LISTED_FAILURES) {
|
|
76
|
+
lines.push(t.muted(` ${t.glyph.ellipsis} ${report.failures.length - MAX_LISTED_FAILURES} more`));
|
|
77
|
+
}
|
|
78
|
+
// Say what is NOT known, rather than letting a bare "tests failed" imply the
|
|
79
|
+
// details were simply absent from the run.
|
|
80
|
+
if (!report.passed && report.failures.length === 0) {
|
|
81
|
+
lines.push(t.muted(" no individual failures were recognized in the output"));
|
|
82
|
+
}
|
|
83
|
+
if (report.outputTruncated && report.failures.length > 0) {
|
|
84
|
+
lines.push(t.muted(" output was capped — the failures above may be incomplete"));
|
|
85
|
+
}
|
|
86
|
+
return Number.isFinite(width)
|
|
87
|
+
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
88
|
+
: lines;
|
|
89
|
+
}
|
|
@@ -5,6 +5,8 @@ import { createStreamHighlighter, } from "./highlight.js";
|
|
|
5
5
|
import { fit } from "./layout.js";
|
|
6
6
|
import { createFrameClock, inTransition, spinnerGlyph, } from "./motion.js";
|
|
7
7
|
import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
|
|
8
|
+
import { changedSteps, stepStatusLine } from "./plan-view.js";
|
|
9
|
+
import { testResultLines } from "./test-view.js";
|
|
8
10
|
/** Erase the current line and return the cursor to column 0. */
|
|
9
11
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
10
12
|
/**
|
|
@@ -52,6 +54,8 @@ export class TtyRenderer {
|
|
|
52
54
|
rawStatus = null;
|
|
53
55
|
phase = null;
|
|
54
56
|
progressState = null;
|
|
57
|
+
/** Last plan snapshot committed, so `setPlan` notes only what changed. */
|
|
58
|
+
planSteps = [];
|
|
55
59
|
/** When the current phase *identity* began — drives honest elapsed display. */
|
|
56
60
|
phaseStartedAt = 0;
|
|
57
61
|
/** In-flight tool call (serial by contract) for end-note duration. */
|
|
@@ -277,6 +281,42 @@ export class TtyRenderer {
|
|
|
277
281
|
this.progressState = state;
|
|
278
282
|
this.refresh();
|
|
279
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Commit one trail line per step whose status actually changed (P3). This
|
|
286
|
+
* medium owns a single live line, which `progress` already uses for the
|
|
287
|
+
* `[i/n] title` prefix — the checklist itself belongs in committed output,
|
|
288
|
+
* exactly where the plan executor used to write it through the approval io.
|
|
289
|
+
*
|
|
290
|
+
* `note` is the right sink: it hides the live line, writes, and lets the next
|
|
291
|
+
* transition redraw — so a step line can never land inside the managed row.
|
|
292
|
+
*/
|
|
293
|
+
setPlan(steps) {
|
|
294
|
+
if (this.closed)
|
|
295
|
+
return;
|
|
296
|
+
if (steps === null) {
|
|
297
|
+
this.planSteps = [];
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const step of changedSteps(this.planSteps, steps)) {
|
|
301
|
+
this.note(stepStatusLine(step, this.theme));
|
|
302
|
+
}
|
|
303
|
+
this.planSteps = steps.map((s) => ({ ...s }));
|
|
304
|
+
}
|
|
305
|
+
/** Committed via `note`, so the live line yields before the block lands. */
|
|
306
|
+
/**
|
|
307
|
+
* Ignored: a served tier is standing state, not an event. Committing a line
|
|
308
|
+
* per request would be noise on an append-only surface, and the run's tier is
|
|
309
|
+
* already reported by `cruxy usage`.
|
|
310
|
+
*/
|
|
311
|
+
servedRouting() {
|
|
312
|
+
// no-op
|
|
313
|
+
}
|
|
314
|
+
testResult(report) {
|
|
315
|
+
if (this.closed)
|
|
316
|
+
return;
|
|
317
|
+
for (const line of testResultLines(report, this.theme, this.caps.width))
|
|
318
|
+
this.note(line);
|
|
319
|
+
}
|
|
280
320
|
toolLifecycle(event) {
|
|
281
321
|
if (this.closed)
|
|
282
322
|
return;
|
package/dist/routing/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
|
|
3
|
+
export { MODEL_CHOICES, SessionModel, describeModelChoice, parseModelChoice, } from "./session-model.js";
|
|
3
4
|
// `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
|
|
4
5
|
// mapping is internal to routing, so it can never be reached from a user-facing
|
|
5
6
|
// render path (the internal-mapping-isolation guarantee, U.8).
|
package/dist/routing/router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
2
|
import { routingTierUnavailable } from "../errors/index.js";
|
|
3
3
|
import { resolveModelId } from "./resolve.js";
|
|
4
|
+
import { AUTO_MODEL, } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
6
7
|
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
@@ -73,12 +74,20 @@ export function routerForConfig(config) {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
/**
|
|
76
|
-
* Resolve a declared task class to
|
|
77
|
-
* surfacing (the U.4 state line)
|
|
78
|
-
*
|
|
77
|
+
* Resolve a declared task class to the wire model for the request, plus the tier
|
|
78
|
+
* for honest surfacing (the U.4 state line) when there is one. The model id
|
|
79
|
+
* comes from the internal {@link resolveModelId} — callers never touch that
|
|
79
80
|
* mapping directly, so it stays the single source of truth.
|
|
81
|
+
*
|
|
82
|
+
* `tier` is ABSENT exactly when the router declined and the request is going out
|
|
83
|
+
* as {@link AUTO_MODEL}: no tier has been chosen client-side, so there is
|
|
84
|
+
* nothing honest to put on the status line until the gateway's opening frame
|
|
85
|
+
* says what it actually served. Absent is the whole point — a fabricated
|
|
86
|
+
* "probably vaani" here is precisely the claim `auto` cannot support.
|
|
80
87
|
*/
|
|
81
88
|
export function resolveTaskModel(router, taskClass) {
|
|
82
89
|
const tier = router.select(taskClass);
|
|
83
|
-
return
|
|
90
|
+
return tier === null
|
|
91
|
+
? { model: AUTO_MODEL }
|
|
92
|
+
: { tier, model: resolveModelId(tier) };
|
|
84
93
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
import { multiplierForTier } from "../usage/weighted.js";
|
|
3
|
+
import { AUTO_MODEL } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything `/model` offers, in the order it lists them. `auto` leads because
|
|
6
|
+
* it is the default and because it is the only entry that is not a tier — a
|
|
7
|
+
* list that buried it among the three would read as a four-tier menu.
|
|
8
|
+
*/
|
|
9
|
+
export const MODEL_CHOICES = [
|
|
10
|
+
AUTO_MODEL,
|
|
11
|
+
...MODEL_TIERS,
|
|
12
|
+
];
|
|
13
|
+
/** Parse a user-typed model name, or `null` — never a guess, never a nearest match. */
|
|
14
|
+
export function parseModelChoice(text) {
|
|
15
|
+
const want = text.trim().toLowerCase();
|
|
16
|
+
return MODEL_CHOICES.includes(want)
|
|
17
|
+
? want
|
|
18
|
+
: null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One line describing a choice, for the picker and for `/model` with no picker.
|
|
22
|
+
*
|
|
23
|
+
* THE ONLY THING SAID ABOUT A TIER IS ITS WEIGHT, and that is deliberate. cruxy
|
|
24
|
+
* publishes no capability ordering for `kavi`/`vaani`/`mira` — nothing in this
|
|
25
|
+
* codebase knows which is "smarter" — so a description like "fastest" or "best
|
|
26
|
+
* for hard problems" would be invented at the exact moment a user is deciding
|
|
27
|
+
* on it. The weighted multiplier is a real, checkable number this CLI already
|
|
28
|
+
* meters with (`TIER_MULTIPLIERS`, mirrored from the gateway's budget config),
|
|
29
|
+
* so it is what the picker says. A tier this build has no multiplier for gets no
|
|
30
|
+
* claim at all rather than a made-up one.
|
|
31
|
+
*/
|
|
32
|
+
export function describeModelChoice(choice) {
|
|
33
|
+
if (choice === AUTO_MODEL) {
|
|
34
|
+
return "the gateway picks a tier per request (not a tier itself)";
|
|
35
|
+
}
|
|
36
|
+
const multiplier = multiplierForTier(choice);
|
|
37
|
+
return multiplier === undefined
|
|
38
|
+
? "a routing tier"
|
|
39
|
+
: `a routing tier · ${multiplier}× weighted`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The session's model choice, and the router every part of the session selects
|
|
43
|
+
* through.
|
|
44
|
+
*/
|
|
45
|
+
export class SessionModel {
|
|
46
|
+
choice;
|
|
47
|
+
base;
|
|
48
|
+
listeners = new Set();
|
|
49
|
+
/**
|
|
50
|
+
* @param initial the starting choice — `config.model.model` when it names one
|
|
51
|
+
* of {@link MODEL_CHOICES}, else `auto`.
|
|
52
|
+
* @param base the config-driven router (`routing.default` / `routing.map`),
|
|
53
|
+
* or null when the user configured none. Consulted ONLY while
|
|
54
|
+
* the choice is `auto`: a tier picked at runtime is an explicit
|
|
55
|
+
* instruction for this session and outranks a config table, the
|
|
56
|
+
* same precedence a CLI flag has over a config file.
|
|
57
|
+
*/
|
|
58
|
+
constructor(initial, base = null) {
|
|
59
|
+
this.choice = initial;
|
|
60
|
+
this.base = base;
|
|
61
|
+
}
|
|
62
|
+
/** The current choice. The ONE read every surface makes. */
|
|
63
|
+
current() {
|
|
64
|
+
return this.choice;
|
|
65
|
+
}
|
|
66
|
+
/** Whether a config routing table is in play — so `/model` can say when a pick overrides it. */
|
|
67
|
+
get hasRoutingTable() {
|
|
68
|
+
return this.base !== null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Adopt a new choice. Returns whether anything changed, so a caller can stay
|
|
72
|
+
* quiet about a no-op rather than announcing a switch that did not happen.
|
|
73
|
+
* Listeners fire only on a real change.
|
|
74
|
+
*/
|
|
75
|
+
set(choice) {
|
|
76
|
+
if (choice === this.choice)
|
|
77
|
+
return false;
|
|
78
|
+
this.choice = choice;
|
|
79
|
+
for (const listener of this.listeners)
|
|
80
|
+
listener();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to changes; returns the unsubscribe. The renderer uses this to
|
|
85
|
+
* drop the served tier the moment the choice moves — see the note on
|
|
86
|
+
* `TuiRenderer.attachModel`.
|
|
87
|
+
*/
|
|
88
|
+
onChange(listener) {
|
|
89
|
+
this.listeners.add(listener);
|
|
90
|
+
return () => {
|
|
91
|
+
this.listeners.delete(listener);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A pinned tier wins for every task class. `auto` defers to the config table
|
|
96
|
+
* when there is one (unchanged C.30 behaviour) and otherwise declines, which
|
|
97
|
+
* sends the request out as `auto` for the gateway to route.
|
|
98
|
+
*
|
|
99
|
+
* A pinned tier deliberately flattens the per-task map. The user named one
|
|
100
|
+
* model for this session; honouring `routing.map` underneath would quietly
|
|
101
|
+
* send some of their work somewhere else, which is the silent-substitution
|
|
102
|
+
* failure `ConfigRouter` already refuses to make.
|
|
103
|
+
*/
|
|
104
|
+
select(taskClass) {
|
|
105
|
+
if (this.choice !== AUTO_MODEL)
|
|
106
|
+
return this.choice;
|
|
107
|
+
return this.base === null ? null : this.base.select(taskClass);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/routing/types.js
CHANGED
|
@@ -25,3 +25,17 @@ export const TASK_CLASSES = [
|
|
|
25
25
|
/** Context compaction / summarization. */
|
|
26
26
|
"summarize",
|
|
27
27
|
];
|
|
28
|
+
/**
|
|
29
|
+
* The server-routing sentinel: a valid `model.model` (and cruxy's default), but
|
|
30
|
+
* deliberately **not** a {@link Tier} and never a member of `MODEL_TIERS`.
|
|
31
|
+
*
|
|
32
|
+
* The distinction is load-bearing rather than pedantic. A tier names one model
|
|
33
|
+
* for the whole run, fixed before `provider.stream` opens. `auto` names no model
|
|
34
|
+
* at all — the gateway resolves it PER REQUEST and may downgrade it under budget
|
|
35
|
+
* pressure, which is why the served tier arrives on the stream's opening frame
|
|
36
|
+
* and why `auto` can never be reported as "the tier this run used". Anything
|
|
37
|
+
* that shows a model choice has to say which of the two it is holding; a picker
|
|
38
|
+
* that listed `auto` alongside the tiers as though it were a fourth one would be
|
|
39
|
+
* asserting a fixed model where there is none.
|
|
40
|
+
*/
|
|
41
|
+
export const AUTO_MODEL = "auto";
|
|
@@ -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
|
+
}
|