ucode-agent 1.0.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.
@@ -0,0 +1,237 @@
1
+ ---
2
+ name: ui-ux
3
+ description: How to design and build an interface that looks deliberate rather than generated — direction, tokens, layout, states, motion, accessibility. Loads itself for any work with a user interface in it.
4
+ auto: app, apps, ui, ux, website, web app, webapp, web page, webpage, landing page, dashboard, frontend, front-end, interface, css, tailwind, html, react, vue, svelte, nextjs, next.js, redesign, restyle, responsive, dark mode, ugly, styling, stylesheet, mockup, prototype
5
+ ---
6
+
7
+ # Interfaces
8
+
9
+ The house style of a language model is a centred column, a purple-to-blue
10
+ gradient, three equal cards and a lot of empty space. It is recognisable on
11
+ sight, and everyone has now seen it a thousand times. Your job is to not
12
+ produce it.
13
+
14
+ This is not decoration applied at the end. It is the order the work happens in:
15
+ direction, then structure, then tokens, then states, then verification.
16
+
17
+ ## 1. Decide the direction before writing any CSS
18
+
19
+ Answer these to yourself in one line each, then build to the answers:
20
+
21
+ 1. **Job** — what does this screen actually do?
22
+ 2. **Who** — who opens it, how often, and what do they need first?
23
+ 3. **Tone** — pick one and commit: utilitarian, editorial, technical, playful,
24
+ industrial, calm, dense. "Modern and clean" is not a tone, it is a way of
25
+ avoiding the question.
26
+ 4. **One memorable detail** — a colour, a texture, a typographic move, a single
27
+ interaction. Exactly one. It is the difference between a design and a
28
+ template.
29
+
30
+ ## 2. Pick the mode from the surface, not the product
31
+
32
+ The mode names what success looks like for the person in front of it. It
33
+ decides how much the interface is allowed to perform.
34
+
35
+ - **Operate** — they are completing a task. App UI, dashboards, editors,
36
+ admin, settings, tools. Scannability, consistency and speed beat expression
37
+ every time. The personality lives in precise details, not in the hero.
38
+ - **Persuade** — they are deciding whether to act. Landing pages, pricing,
39
+ marketing. Here the design *is* the product; earn the attention.
40
+ - **Read** — they are trying to understand something. Docs, articles, guides.
41
+ Structure for comprehension first, then make reading pleasant enough to stay.
42
+ - **Experience** — they are looking at the work itself. Portfolios, galleries.
43
+ The artifact leads from the first screen and the interface gets out of the way.
44
+
45
+ A tool's landing page is still Persuade. A dashboard is still Operate however
46
+ beautiful the brand is. Never put a marketing hero on top of a working tool.
47
+
48
+ ## 3. Tokens first, then never a raw value again
49
+
50
+ Set these at the top and use them everywhere. One-off hard-coded values are
51
+ exactly how a design drifts out of alignment with itself.
52
+
53
+ ```css
54
+ :root {
55
+ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
56
+ --mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
57
+
58
+ /* One scale. Nothing between the steps. */
59
+ --text--1: .8125rem; --text-0: 1rem; --text-1: 1.25rem;
60
+ --text-2: 1.5rem; --text-3: 2rem; --text-4: 2.75rem;
61
+
62
+ --s1: .25rem; --s2: .5rem; --s3: .75rem; --s4: 1rem;
63
+ --s5: 1.5rem; --s6: 2rem; --s7: 3rem; --s8: 4rem;
64
+
65
+ /* Neutrals carry a hue. Flat #808080 grey is what makes a UI look dead. */
66
+ --bg: #fbfaf9; --surface: #ffffff; --line: #e6e2dd;
67
+ --ink: #17161a; --ink-2: #55525c; --ink-3: #8a8792;
68
+
69
+ --accent: #2f6fe0; --accent-ink: #ffffff; --accent-soft: #eaf1fe;
70
+ --danger: #b42318; --ok: #217a4b; --warn: #b25e09;
71
+
72
+ --radius: 10px; --radius-sm: 6px;
73
+ --shadow: 0 1px 2px rgb(20 18 24 / .05), 0 8px 24px rgb(20 18 24 / .07);
74
+ --focus: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
75
+ }
76
+
77
+ @media (prefers-color-scheme: dark) {
78
+ :root {
79
+ --bg: #101014; --surface: #17171c; --line: #2a2a33;
80
+ --ink: #f2f1f5; --ink-2: #b3b0bd; --ink-3: #807d8a;
81
+ --accent: #6fa4ff; --accent-ink: #0f1016; --accent-soft: #16203a;
82
+ }
83
+ }
84
+
85
+ * { box-sizing: border-box; }
86
+ body {
87
+ margin: 0; background: var(--bg); color: var(--ink);
88
+ font: var(--text-0)/1.6 var(--font);
89
+ -webkit-font-smoothing: antialiased;
90
+ }
91
+ h1, h2, h3 { margin: 0; line-height: 1.15; letter-spacing: -.02em; }
92
+ :focus-visible { outline: none; box-shadow: var(--focus); }
93
+ @media (prefers-reduced-motion: reduce) {
94
+ *, *::before, *::after { animation: none !important; transition: none !important; }
95
+ }
96
+ ```
97
+
98
+ **Swap the four palette lines to change direction, and keep everything else:**
99
+
100
+ - **Paper** — `--bg:#faf9f7; --surface:#fff; --ink:#1a1815; --accent:#b45309`.
101
+ Warm, calm, for reading and for tools used daily.
102
+ - **Console** — `--bg:#0d1117; --surface:#161b22; --ink:#e6edf3; --accent:#2f81f7`
103
+ with `--line:#30363d`. Technical and dense, for dashboards and dev tools.
104
+ - **Editorial** — `--bg:#fffdf8; --surface:#fff; --ink:#141414; --accent:#c2410c`
105
+ plus a serif on headings only. For content and landing pages.
106
+
107
+ One direction, all the way through. Half Console and half Editorial reads as a
108
+ mistake, because it is one.
109
+
110
+ **The rules behind the tokens**, for when you need a value that is not in them:
111
+
112
+ - **Spacing** — every value from the scale. Space *inside* a group must be
113
+ smaller than the space *around* it, or the grouping reads wrong however good
114
+ the rest is.
115
+ - **Type** — one family for UI, at most one more for display. Body 15–17px,
116
+ line-height 1.5–1.65, measure capped at 65–75ch. Headings tighter: 1.1–1.25,
117
+ and `letter-spacing: -.02em` above 28px.
118
+ - **Colour** — one accent hue, one neutral ramp, semantic red/amber/green. Never
119
+ pure `#000` on pure `#fff`.
120
+ - **Radius** — pick one and derive: inputs and buttons 6–8, cards 10–12,
121
+ pills 999. Four unrelated radii look like an accident.
122
+ - **Depth** — borders and background steps first, shadows last, and only for
123
+ things that genuinely float: menus, modals, toasts. A shadow on every card
124
+ flattens the hierarchy instead of building it.
125
+ - **Dark mode** — swap the variables. Never invert. Surfaces get *lighter* as
126
+ they rise, and pure white on near-black is too harsh: use around 90%.
127
+
128
+ ## 4. Layout
129
+
130
+ - Build hierarchy with size, weight and colour before reaching for a box.
131
+ Three levels — primary, secondary, muted — is usually all you need.
132
+ - Align to a grid and share edges. Ragged left edges are the single most
133
+ common reason a page feels amateur.
134
+ - Full width is not a layout. Constrain content to what the content needs: a
135
+ table wants width, prose does not.
136
+ - One job per element. A card that is a link, a form and a menu is three cards.
137
+
138
+ ## 5. States are most of the work
139
+
140
+ An interface that only handles the happy path is a mockup. For every screen:
141
+
142
+ - **Empty** — first run, nothing there yet. Say what this is and how to make
143
+ the first one. Never a blank box.
144
+ - **Loading** — skeletons shaped like the real content, or a spinner on the
145
+ control that was pressed. Do not blank the page.
146
+ - **Error** — what failed and what they can do about it. Keep their input.
147
+ - **Partial** — one row failed and the rest loaded.
148
+
149
+ And for every interactive element: `:hover`, `:focus-visible`, `:active`,
150
+ `:disabled`, and the selected state. A control with only a default state is
151
+ unfinished, not minimal. `outline: none` with no replacement focus ring is a
152
+ bug, not a style choice.
153
+
154
+ ```css
155
+ .btn {
156
+ font: 500 var(--text-0)/1 var(--font);
157
+ padding: var(--s3) var(--s5);
158
+ border: 1px solid transparent; border-radius: var(--radius-sm);
159
+ background: var(--accent); color: var(--accent-ink);
160
+ cursor: pointer; transition: filter .15s, transform .05s;
161
+ }
162
+ .btn:hover { filter: brightness(1.08); }
163
+ .btn:active { transform: translateY(1px); }
164
+ .btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
165
+ .btn--quiet { background: transparent; color: var(--ink); border-color: var(--line); }
166
+ .btn--quiet:hover { background: var(--accent-soft); }
167
+
168
+ .input {
169
+ width: 100%; padding: var(--s3) var(--s4);
170
+ font: var(--text-0) var(--font); color: var(--ink);
171
+ background: var(--surface);
172
+ border: 1px solid var(--line); border-radius: var(--radius-sm);
173
+ }
174
+ .input::placeholder { color: var(--ink-3); }
175
+ ```
176
+
177
+ ## 6. Motion
178
+
179
+ One authored moment, not effects scattered everywhere, and not the same
180
+ entrance animation on every section. 120–200ms, on `transform`, `opacity`,
181
+ `filter` and colour only — never on `height` or `width`. Ease out from a state
182
+ that is already visible. Honour `prefers-reduced-motion` every time.
183
+
184
+ ## 7. Non-negotiable
185
+
186
+ - Contrast 4.5:1 for body text and placeholders, 3:1 for large text and for
187
+ the borders of controls.
188
+ - Every control reachable and operable by keyboard, in the order it appears.
189
+ - Labels on inputs. A placeholder is not a label.
190
+ - Touch targets 44px.
191
+ - `<button>` for actions, `<a href>` for navigation. Never a `<div>` with an
192
+ onClick.
193
+ - `aria-label` on any icon-only button; real `alt` text on meaningful images.
194
+ - Nothing shifts as content loads: reserve the space, set image dimensions.
195
+ - 16px minimum body text on mobile, and `<meta name="viewport" content="width=device-width, initial-scale=1">`.
196
+
197
+ ## 8. Do not
198
+
199
+ These are the defaults of the category rather than laws — a brief can earn any
200
+ of them — but reaching for one *because it was the first thing to hand* means
201
+ you were not deciding.
202
+
203
+ - Purple-to-blue gradient headers. Gradient text. Glassmorphism as decoration.
204
+ - Three identical feature cards with a lorem sentence each. Same-size icon +
205
+ heading + text cards used as the whole page structure. Nested cards.
206
+ - The hero-metric template: big number, small label, three supporting stats.
207
+ - A tracked uppercase eyebrow over every section, or 01 / 02 / 03 section
208
+ numbers where the order carries no information.
209
+ - Emoji as interface icons. Use an icon set or well-drawn inline SVG.
210
+ - Monospace as a costume for "technical" when there is no code or data in it.
211
+ - A modal for something that needs neither interruption nor protected focus.
212
+ - Centring everything. Long centred paragraphs are genuinely harder to read.
213
+ - Inventing a component library when the project already has one. Look for
214
+ existing components, tokens and utilities first, and use them.
215
+ - Placeholder copy. Write the real words — they are part of the design.
216
+
217
+ ## 9. Before you say it is done
218
+
219
+ Open it and look at it. Then walk this list and fix what fails. The last three
220
+ are the ones that get skipped, so do not skip them:
221
+
222
+ 1. **375px wide.** No horizontal scrollbar, nothing overlapping, nothing cut
223
+ off. If you wrote no media query at all, you have not done this.
224
+ 2. **The longest realistic string** in every label and every cell. Does the row
225
+ hold, or does one long title break the layout?
226
+ 3. **Empty data.** Is there a real empty state, or a blank rectangle?
227
+ 4. **Keyboard only.** Tab through everything. Can you see where you are at
228
+ every step?
229
+ 5. **Count the accent hues.** More than one family means the palette got away
230
+ from you. Put it back.
231
+ 6. **Contrast.** `--ink-3` on `--bg` is for hints, never for anything that has
232
+ to be read.
233
+
234
+ Then say which of these you actually checked and what you found. Do not claim
235
+ it works on mobile if you never made it narrow. If it runs in a browser, start
236
+ it with `run_command` and `background: true` and open it before you call it
237
+ finished.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: write-tests
3
+ description: Write tests that fail for the right reason — behaviour over implementation, real edges, and no assertions that can never break.
4
+ ---
5
+
6
+ # Writing tests
7
+
8
+ The test that matters is the one that fails the day someone breaks the thing it
9
+ covers. Every other test is overhead with a green tick on it.
10
+
11
+ ## Test behaviour, not implementation
12
+
13
+ Assert on what a caller can observe: the return value, the state afterwards,
14
+ the thing that was written. A test that checks a private helper was called
15
+ locks the implementation in place and will need rewriting the first time
16
+ anyone refactors — while still not proving the feature works.
17
+
18
+ ## Cover the edges, not five versions of the middle
19
+
20
+ For each unit, the ones that actually find bugs:
21
+
22
+ - empty, one, many
23
+ - the boundary: 0, -1, the last index, the maximum
24
+ - null, undefined, the missing field, the wrong type
25
+ - the failure path: the dependency throws, the network times out, the file is
26
+ gone
27
+ - the second call: is it idempotent, is state left behind?
28
+
29
+ ## Make each test readable on its own
30
+
31
+ - The name says the behaviour: `returns null when the session file is missing`.
32
+ - Arrange, act, assert, in that order and visibly separated.
33
+ - One reason to fail per test. Six assertions in a row means the first failure
34
+ hides the other five.
35
+ - No shared mutable state between tests, and no dependence on the order they
36
+ run in.
37
+ - Real values over mocks wherever it is affordable. Mock the network and the
38
+ clock; do not mock the thing you are testing.
39
+
40
+ ## Prove the test works
41
+
42
+ Break the code on purpose and watch the test fail, then put it back. A test
43
+ that has never failed is a test you have no reason to trust. If a test cannot
44
+ fail — an assertion on a constant, a mock asserting itself — delete it.
45
+
46
+ Then run the whole suite and report the real numbers, including anything that
47
+ was already failing before you started.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * failure.js — the single error shape used everywhere in ucode.
3
+ *
4
+ * A stack trace tells the user what line broke. It does not tell them what
5
+ * ucode was trying to do, or what they should do about it. Every failure in
6
+ * this program carries those three things as fields, so no screen ever has to
7
+ * fall back on a raw trace to explain itself.
8
+ */
9
+
10
+ export class Failure extends Error {
11
+ /**
12
+ * @param {object} o
13
+ * @param {string} o.kind machine-readable category, e.g. 'rate_limit'
14
+ * @param {string} o.attempted what was happening: 'reading src/app.js'
15
+ * @param {string} o.failed what went wrong, in plain words
16
+ * @param {string} [o.fix] the concrete next step
17
+ * @param {Error} [o.cause] the underlying error, kept for --debug
18
+ * @param {object} [o.detail] structured extras (status, retryAfter, ...)
19
+ */
20
+ constructor({ kind, attempted, failed, fix, cause, detail }) {
21
+ super(`${attempted}: ${failed}`);
22
+ this.name = 'Failure';
23
+ this.kind = kind;
24
+ this.attempted = attempted;
25
+ this.failed = failed;
26
+ this.fix = fix;
27
+ this.cause = cause;
28
+ this.detail = detail ?? {};
29
+ }
30
+ }
31
+
32
+ /**
33
+ * A failure inside a tool.
34
+ *
35
+ * These never reach the user as a crash. They are handed back to the model as
36
+ * text, which is why they read like instructions to whoever caused them — the
37
+ * model can usually fix its own mistake on the next step if it is told what
38
+ * the mistake was.
39
+ */
40
+ export class ToolFailure extends Failure {
41
+ constructor(fields) {
42
+ super(fields);
43
+ this.name = 'ToolFailure';
44
+ }
45
+
46
+ /** What the model is shown in place of a tool result. */
47
+ forModel() {
48
+ const out = [`ERROR (${this.kind}) while ${this.attempted}.`, this.failed];
49
+ if (this.fix) out.push(`Suggestion: ${this.fix}`);
50
+ return out.join('\n');
51
+ }
52
+ }
53
+
54
+ /** The user said no at a confirmation prompt. Their call, not a fault. */
55
+ export class Declined extends ToolFailure {
56
+ constructor(what) {
57
+ super({
58
+ kind: 'declined',
59
+ attempted: what,
60
+ failed: 'The user declined this action.',
61
+ fix: 'Do not try it again. Say what you were going to do and ask how they want to proceed.',
62
+ });
63
+ this.name = 'Declined';
64
+ }
65
+ }
66
+
67
+ /** True for anything carrying the three-field shape, however it was made. */
68
+ export function isFailure(err) {
69
+ return Boolean(err && typeof err === 'object' && err.attempted && err.failed);
70
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * history.js — conversations on disk.
3
+ *
4
+ * One JSON file per session under ~/.ucode/sessions. The file always holds the
5
+ * complete history, tool calls and results included, even when the copy being
6
+ * sent to the model has had its older turns folded into a summary.
7
+ *
8
+ * Saving happens after every turn and after every tool result, so a crash, a
9
+ * Ctrl+C or a closed terminal costs nothing.
10
+ */
11
+
12
+ import { promises as fs } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { Failure } from './failure.js';
17
+
18
+ export const HOME = path.join(os.homedir(), '.ucode');
19
+
20
+ export function sessionsDir(home = HOME) {
21
+ return path.join(home, 'sessions');
22
+ }
23
+
24
+ export function sessionFile(id, home = HOME) {
25
+ return path.join(sessionsDir(home), `${id}.json`);
26
+ }
27
+
28
+ /**
29
+ * A session's label, taken from the first thing the user said.
30
+ *
31
+ * This is what the resume list shows, so it has to read like a name rather
32
+ * than like the top of a paragraph.
33
+ */
34
+ export function titleFrom(text) {
35
+ const first = String(text ?? '')
36
+ .split('\n')
37
+ .map((l) => l.trim())
38
+ .find(Boolean);
39
+
40
+ if (!first) return 'Untitled';
41
+
42
+ let title = first
43
+ .replace(/^\/+/, '')
44
+ .replace(/^[-*>#\s]+/, '')
45
+ .replace(/\s+/g, ' ')
46
+ .trim();
47
+
48
+ if (!title) return 'Untitled';
49
+ if (title.length > 60) title = `${title.slice(0, 59).trimEnd()}…`;
50
+ return title[0].toUpperCase() + title.slice(1);
51
+ }
52
+
53
+ export function newSession(cwd = process.cwd(), model = '') {
54
+ const now = new Date().toISOString();
55
+ return {
56
+ id: `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`,
57
+ title: 'Untitled',
58
+ cwd: path.resolve(cwd),
59
+ model,
60
+ createdAt: now,
61
+ updatedAt: now,
62
+ usage: { promptTokens: 0, outputTokens: 0, totalTokens: 0, turns: 0 },
63
+ messages: [],
64
+ };
65
+ }
66
+
67
+ async function ensureDir(home) {
68
+ const dir = sessionsDir(home);
69
+ try {
70
+ await fs.mkdir(dir, { recursive: true });
71
+ } catch (err) {
72
+ throw new Failure({
73
+ kind: 'sessions_unwritable',
74
+ attempted: `creating ${dir}`,
75
+ failed: `${err.code ?? ''} ${err.message}`.trim(),
76
+ fix: 'Check that your home directory is writable, or point HOME somewhere that is.',
77
+ cause: err,
78
+ });
79
+ }
80
+ return dir;
81
+ }
82
+
83
+ /**
84
+ * Write the session out.
85
+ *
86
+ * Via a temp file and a rename, so a save interrupted halfway can never leave
87
+ * a truncated session behind — the previous good file stays until the new one
88
+ * is complete.
89
+ */
90
+ export async function save(session, { home = HOME } = {}) {
91
+ await ensureDir(home);
92
+
93
+ session.updatedAt = new Date().toISOString();
94
+ if (!session.title || session.title === 'Untitled') {
95
+ const first = session.messages.find((m) => m.role === 'user');
96
+ if (first) session.title = titleFrom(first.content);
97
+ }
98
+
99
+ const target = sessionFile(session.id, home);
100
+ const temp = `${target}.${process.pid}.tmp`;
101
+
102
+ try {
103
+ await fs.writeFile(temp, JSON.stringify(session, null, 2), 'utf8');
104
+ await fs.rename(temp, target);
105
+ } catch (err) {
106
+ await fs.rm(temp, { force: true }).catch(() => {});
107
+ throw new Failure({
108
+ kind: 'save_failed',
109
+ attempted: `saving this session to ${target}`,
110
+ failed: `${err.code ?? ''} ${err.message}`.trim(),
111
+ fix:
112
+ 'Check free space and permissions on ~/.ucode/sessions. The conversation is ' +
113
+ 'still in memory, so fixing it means the next turn saves everything.',
114
+ cause: err,
115
+ });
116
+ }
117
+
118
+ return target;
119
+ }
120
+
121
+ export async function load(id, { home = HOME } = {}) {
122
+ const file = sessionFile(id, home);
123
+ let raw;
124
+
125
+ try {
126
+ raw = await fs.readFile(file, 'utf8');
127
+ } catch (err) {
128
+ if (err.code === 'ENOENT') {
129
+ throw new Failure({
130
+ kind: 'no_such_session',
131
+ attempted: `resuming ${id}`,
132
+ failed: `There is no session file at ${file}.`,
133
+ fix: 'Run /resume to see the sessions that do exist.',
134
+ cause: err,
135
+ });
136
+ }
137
+ throw new Failure({
138
+ kind: 'session_unreadable',
139
+ attempted: `reading session ${id}`,
140
+ failed: `${err.code ?? ''} ${err.message}`.trim(),
141
+ fix: 'Check permissions on ~/.ucode/sessions.',
142
+ cause: err,
143
+ });
144
+ }
145
+
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(raw);
149
+ } catch (err) {
150
+ throw new Failure({
151
+ kind: 'session_corrupt',
152
+ attempted: `resuming ${id}`,
153
+ failed: `${file} is not valid JSON (${err.message}) — most likely truncated by a hard kill.`,
154
+ fix: `Start fresh with /new. Deleting ${file} is safe; it only affects that one conversation.`,
155
+ cause: err,
156
+ });
157
+ }
158
+
159
+ if (!parsed || !Array.isArray(parsed.messages)) {
160
+ throw new Failure({
161
+ kind: 'session_corrupt',
162
+ attempted: `resuming ${id}`,
163
+ failed: `${file} is valid JSON but holds no message history.`,
164
+ fix: `Start fresh with /new. The file is at ${file} if you want to look at it.`,
165
+ });
166
+ }
167
+
168
+ // Fill in whatever an older or hand-edited file happens to be missing.
169
+ return {
170
+ usage: { promptTokens: 0, outputTokens: 0, totalTokens: 0, turns: 0 },
171
+ title: 'Untitled',
172
+ cwd: process.cwd(),
173
+ model: '',
174
+ createdAt: parsed.updatedAt ?? new Date().toISOString(),
175
+ ...parsed,
176
+ id: parsed.id ?? id,
177
+ };
178
+ }
179
+
180
+ /** The opening line of a conversation, for the resume list. */
181
+ function previewOf(messages) {
182
+ const first = messages.find((m) => m.role === 'user' && m.content?.trim());
183
+ if (!first) return '';
184
+ const line = first.content.split('\n').map((l) => l.trim()).find(Boolean) ?? '';
185
+ return line.length > 96 ? `${line.slice(0, 95)}…` : line;
186
+ }
187
+
188
+ /** When something last happened, and when it started. */
189
+ function lastReplyOf(messages) {
190
+ const last = [...messages].reverse().find((m) => m.role === 'assistant' && m.content?.trim());
191
+ if (!last) return '';
192
+ const line = last.content.split('\n').map((l) => l.trim()).find(Boolean) ?? '';
193
+ return line.length > 96 ? `${line.slice(0, 95)}…` : line;
194
+ }
195
+
196
+ /**
197
+ * Every saved session, newest first, with the ones belonging to this folder
198
+ * pulled to the front — which is the whole point of the list when you have
199
+ * conversations spread across a dozen projects.
200
+ *
201
+ * A file that will not parse is reported separately rather than breaking the
202
+ * listing: one bad session must never hide the other forty.
203
+ */
204
+ export async function list({ home = HOME, cwd = null } = {}) {
205
+ const dir = sessionsDir(home);
206
+
207
+ let files;
208
+ try {
209
+ files = await fs.readdir(dir);
210
+ } catch (err) {
211
+ if (err.code === 'ENOENT') return Object.assign([], { unreadable: [] });
212
+ throw new Failure({
213
+ kind: 'list_failed',
214
+ attempted: `listing ${dir}`,
215
+ failed: `${err.code ?? ''} ${err.message}`.trim(),
216
+ fix: 'Check that ~/.ucode/sessions exists and is readable.',
217
+ cause: err,
218
+ });
219
+ }
220
+
221
+ const here = cwd ? path.resolve(cwd) : null;
222
+ const sessions = [];
223
+ const unreadable = [];
224
+
225
+ for (const file of files) {
226
+ if (!file.endsWith('.json')) continue;
227
+ try {
228
+ const parsed = JSON.parse(await fs.readFile(path.join(dir, file), 'utf8'));
229
+ if (!parsed || !Array.isArray(parsed.messages)) throw new Error('no messages');
230
+ const where = parsed.cwd ?? '';
231
+ const turns = parsed.messages.filter((m) => m.role === 'user').length;
232
+ sessions.push({
233
+ id: parsed.id ?? path.basename(file, '.json'),
234
+ title: parsed.title ?? 'Untitled',
235
+ cwd: where,
236
+ model: parsed.model ?? '',
237
+ createdAt: parsed.createdAt ?? parsed.updatedAt ?? null,
238
+ updatedAt: parsed.updatedAt ?? parsed.createdAt ?? null,
239
+ messageCount: parsed.messages.length,
240
+ turns,
241
+ preview: previewOf(parsed.messages),
242
+ lastReply: lastReplyOf(parsed.messages),
243
+ usage: parsed.usage ?? null,
244
+ mine: here ? path.resolve(where || '.') === here : false,
245
+ });
246
+ } catch {
247
+ unreadable.push(file);
248
+ }
249
+ }
250
+
251
+ const byRecency = (a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''));
252
+ const ordered = here
253
+ ? [...sessions.filter((s) => s.mine).sort(byRecency), ...sessions.filter((s) => !s.mine).sort(byRecency)]
254
+ : sessions.sort(byRecency);
255
+
256
+ return Object.assign(ordered, { unreadable });
257
+ }
258
+
259
+ /** The most recent session started in this folder, if there is one. */
260
+ export async function latestHere(cwd, { home = HOME } = {}) {
261
+ const all = await list({ home, cwd });
262
+ return all.find((s) => s.mine) ?? null;
263
+ }
264
+
265
+ export async function remove(id, { home = HOME } = {}) {
266
+ await fs.rm(sessionFile(id, home), { force: true });
267
+ }
268
+
269
+ export async function removeAll({ home = HOME } = {}) {
270
+ const dir = sessionsDir(home);
271
+ try {
272
+ for (const file of await fs.readdir(dir)) {
273
+ if (file.endsWith('.json')) await fs.rm(path.join(dir, file), { force: true });
274
+ }
275
+ } catch (err) {
276
+ if (err.code !== 'ENOENT') throw err;
277
+ }
278
+ }