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.
- package/README.md +240 -0
- package/package.json +54 -0
- package/skills/build-app/SKILL.md +81 -0
- package/skills/code-review/SKILL.md +36 -0
- package/skills/debug/SKILL.md +47 -0
- package/skills/ui-ux/SKILL.md +237 -0
- package/skills/write-tests/SKILL.md +47 -0
- package/src/core/failure.js +70 -0
- package/src/core/history.js +278 -0
- package/src/core/loop.js +1146 -0
- package/src/core/provider.js +740 -0
- package/src/core/skills.js +165 -0
- package/src/core/window.js +127 -0
- package/src/tools/files.js +466 -0
- package/src/tools/index.js +394 -0
- package/src/tools/search.js +192 -0
- package/src/tools/shared.js +343 -0
- package/src/tools/shell.js +553 -0
- package/src/tools/web.js +96 -0
- package/src/ui/markdown.js +64 -0
- package/src/ui/plain.js +325 -0
- package/src/ui/screen.js +1067 -0
- package/src/ui/theme.js +256 -0
- package/ucode.js +118 -0
|
@@ -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
|
+
}
|