@cruxy/cli 1.2.0 → 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.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. 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;
@@ -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).
@@ -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 `{ tier, model }`: the tier for honest
77
- * surfacing (the U.4 state line), the wire model id for the request. The model
78
- * id comes from the internal {@link resolveModelId} — callers never touch that
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 { tier, model: resolveModelId(tier) };
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
+ }
@@ -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
+ }