ucode-agent 1.5.0 → 1.7.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 (71) hide show
  1. package/README.md +399 -327
  2. package/package.json +6 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/livelog.js +113 -0
  6. package/src/core/loop.js +2105 -1659
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/core/tests.js +86 -0
  10. package/src/tools/blocks.js +117 -0
  11. package/src/tools/browser.js +121 -59
  12. package/src/tools/cache.js +105 -0
  13. package/src/tools/deploy.js +283 -0
  14. package/src/tools/files.js +91 -8
  15. package/src/tools/index.js +634 -495
  16. package/src/tools/rename.js +157 -0
  17. package/src/tools/scaffold.js +85 -6
  18. package/src/tools/shell.js +799 -701
  19. package/src/tools/symbols.js +218 -0
  20. package/src/tools/types.js +179 -0
  21. package/src/ui/activity.js +203 -0
  22. package/src/ui/plain.js +22 -3
  23. package/src/ui/screen.js +65 -19
  24. package/src/ui/theme.js +5 -1
  25. package/templates/blocks/app-shell.tsx +81 -0
  26. package/templates/blocks/data-table.tsx +117 -0
  27. package/templates/blocks/empty-state.tsx +41 -0
  28. package/templates/blocks/page-header.tsx +27 -0
  29. package/templates/blocks/stat-cards.tsx +46 -0
  30. package/templates/next-shadcn/TEMPLATE.md +53 -9
  31. package/templates/next-shadcn/_package-lock.json +1335 -148
  32. package/templates/next-shadcn/components.json +1 -1
  33. package/templates/next-shadcn/next.config.ts +2 -1
  34. package/templates/next-shadcn/package.json +4 -2
  35. package/templates/next-shadcn/presets/citrus.json +77 -0
  36. package/templates/next-shadcn/presets/graphite.json +77 -0
  37. package/templates/next-shadcn/presets/grove.json +77 -0
  38. package/templates/next-shadcn/presets/ocean.json +78 -0
  39. package/templates/next-shadcn/presets/sunset.json +77 -0
  40. package/templates/next-shadcn/presets/violet.json +77 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  45. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  47. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  48. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  49. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  50. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  51. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  52. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  53. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  54. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  55. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  56. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  57. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  58. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  59. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  60. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  61. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  62. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  63. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  64. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  65. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  66. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  67. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  68. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  69. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  70. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  71. package/ucode.js +8 -1
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import dotenv from 'dotenv';
24
24
  import OpenAI from 'openai';
25
+ import { jsonrepair } from 'jsonrepair';
25
26
  import { Failure } from './failure.js';
26
27
 
27
28
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -395,7 +396,14 @@ export function explain(err, id) {
395
396
  (Number.isFinite(asNumber) && asNumber > 0 ? asNumber : null) ??
396
397
  seconds(/try again in ([\dhms.]+)/i.exec(detail)?.[1]) ??
397
398
  null;
398
- const daily = /per day|RPD|TPD|tokens per day/i.test(detail);
399
+ // The daily cap reads "free-models-per-day-high-balance", with hyphens, and
400
+ // names its source in the metadata. It is one cap across every free model,
401
+ // so it is reported at once rather than waited on model after model.
402
+ const meta = body?.error?.metadata ?? {};
403
+ const daily = /per[- ]day|RPD|TPD|daily/i.test(`${detail} ${meta.limit_source ?? ''}`);
404
+ const resetMs = Number(meta.headers?.['X-RateLimit-Reset'] ?? err?.headers?.get?.('x-ratelimit-reset'));
405
+ const resetAt = daily && Number.isFinite(resetMs) && resetMs > Date.now() ? new Date(resetMs) : null;
406
+ const cap = Number(meta.headers?.['X-RateLimit-Limit']) || null;
399
407
  const wait = Number.isFinite(retryAfter) && retryAfter
400
408
  ? (retryAfter >= 60 ? `${Math.ceil(retryAfter / 60)} min` : `${Math.ceil(retryAfter)}s`)
401
409
  : null;
@@ -404,14 +412,14 @@ export function explain(err, id) {
404
412
  kind: 'rate_limit',
405
413
  attempted,
406
414
  failed: daily
407
- ? `The free daily request cap for ${modelName(id)} is used up.`
415
+ ? `This key's free daily limit${cap ? ` of ${cap} requests` : ''} is used up. It covers every free model, so switching will not help.`
408
416
  : `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
409
417
  fix: daily
410
- ? 'Free caps reset each day. /model switches to another one, or add credit to ' +
411
- 'your account to lift the ceiling.'
418
+ ? `It resets ${resetAt ? `at ${resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'once a day'}. ` +
419
+ 'Adding credit to your account raises the limit.'
412
420
  : 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
413
421
  'clears in seconds; /model moves to a quieter one.',
414
- detail: { retryAfter, daily },
422
+ detail: { retryAfter, daily, resetAt: resetAt?.getTime() ?? null },
415
423
  cause: err,
416
424
  });
417
425
  }
@@ -554,7 +562,7 @@ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
554
562
  * @param {Array} messages neutral messages
555
563
  * @param {Array} [tools] neutral tool definitions
556
564
  * @param {object} [opts] { model, temperature, signal, maxOutputTokens,
557
- * onText, onThinking, onWait }
565
+ * reasoning, attempts, onText, onThinking, onWait }
558
566
  */
559
567
  export async function ask(messages, tools = [], opts = {}) {
560
568
  const id = opts.model || current;
@@ -578,8 +586,11 @@ export async function ask(messages, tools = [], opts = {}) {
578
586
  }
579
587
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
580
588
  if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
589
+ if (opts.reasoning) request.reasoning = opts.reasoning;
581
590
 
582
- const attempts = 4;
591
+ // A side call (the design review) passes fewer: it is better skipped than
592
+ // waited on through a string of rate-limit pauses.
593
+ const attempts = opts.attempts ?? 4;
583
594
  let problem;
584
595
 
585
596
  // Text already on screen cannot be unprinted, so a stream is only safe to
@@ -710,7 +721,7 @@ async function streamed(request, opts, id) {
710
721
 
711
722
  const toolCalls = [];
712
723
  for (const [index, slot] of partial) {
713
- toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
724
+ toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args, cutOff: finishReason === 'length' }));
714
725
  }
715
726
 
716
727
  return {
@@ -727,6 +738,56 @@ async function streamed(request, opts, id) {
727
738
  };
728
739
  }
729
740
 
741
+ /**
742
+ * Recover the files from a file-write call whose JSON will not parse.
743
+ *
744
+ * The usual cause is a double quote inside the code that the model forgot to
745
+ * escape — `className="flex"` — which ends the JSON string early. No general
746
+ * repair can know which quote was meant, but a file write has a fixed shape:
747
+ * "path", then "content", then either the next file or the end. Splitting on
748
+ * that shape and escaping the stray quotes gets every file back.
749
+ */
750
+ export function salvageWrites(text) {
751
+ const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
752
+ if (!heads.length) return null;
753
+
754
+ const files = [];
755
+ for (let i = 0; i < heads.length; i++) {
756
+ const from = heads[i].index + heads[i][0].length;
757
+ const to = i + 1 < heads.length ? heads[i + 1].index : text.length;
758
+ // The string ends at the last quote that is followed by nothing but JSON
759
+ // punctuation — `"}, {`, or `"}}, {` when the model added a brace, or `"}]}`.
760
+ const body = text.slice(from, to).replace(/"[\s,{}[\]]*$/, '');
761
+ const content = unescapeLoose(body);
762
+ const pathValue = unescapeLoose(heads[i][1]);
763
+ if (!pathValue || !content) return null; // not the shape we thought — leave it an honest error
764
+ files.push({ path: pathValue, content });
765
+ }
766
+ return files.length ? files : null;
767
+ }
768
+
769
+ /**
770
+ * Decode a JSON string body the forgiving way: the standard escapes are
771
+ * honoured, and everything JSON would reject — a raw line break, a tab, a stray
772
+ * quote, an escape JSON does not know — is kept as the character it plainly is.
773
+ */
774
+ function unescapeLoose(s) {
775
+ const simple = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
776
+ let out = '';
777
+ for (let i = 0; i < s.length; i++) {
778
+ const c = s[i];
779
+ if (c !== '\\' || i === s.length - 1) { out += c; continue; }
780
+ const next = s[++i];
781
+ if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 1, i + 5))) {
782
+ out += String.fromCharCode(parseInt(s.slice(i + 1, i + 5), 16));
783
+ i += 4;
784
+ } else {
785
+ out += simple[next] ?? next;
786
+ }
787
+ }
788
+ return out;
789
+ }
790
+
730
791
  /**
731
792
  * Parse one tool call's arguments.
732
793
  *
@@ -734,7 +795,7 @@ async function streamed(request, opts, id) {
734
795
  * back to the model, which usually fixes its own JSON on the next step —
735
796
  * cheaper than failing the whole turn over a stray comma.
736
797
  */
737
- function readCall({ id, name, raw }) {
798
+ export function readCall({ id, name, raw, cutOff = false }) {
738
799
  const call = { id, name, args: {} };
739
800
  const text = String(raw ?? '').trim();
740
801
  if (!text) return call;
@@ -743,6 +804,28 @@ function readCall({ id, name, raw }) {
743
804
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) call.args = parsed;
744
805
  else call.parseError = `arguments must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`;
745
806
  } catch (err) {
807
+ // A missing comma or a stray control character in a 3,000-token
808
+ // batch_write used to throw the whole step away — half a minute of output
809
+ // discarded over one character. Repair the usual slips instead. Never when
810
+ // the reply was cut off at the output limit, though: repairing that would
811
+ // close the string and quietly write half a file.
812
+ if (!cutOff) {
813
+ try {
814
+ const fixed = JSON.parse(jsonrepair(text));
815
+ if (fixed && typeof fixed === 'object' && !Array.isArray(fixed)) {
816
+ call.args = fixed;
817
+ call.repaired = true;
818
+ return call;
819
+ }
820
+ } catch { /* beyond general repair — try the file-write shape next */ }
821
+
822
+ const salvaged = (name === 'write_file' || name === 'batch_write') ? salvageWrites(text) : null;
823
+ if (salvaged) {
824
+ call.args = name === 'write_file' ? salvaged[0] : { files: salvaged };
825
+ call.repaired = true;
826
+ return call;
827
+ }
828
+ }
746
829
  call.parseError = `${err.message} — the raw arguments were: ${text.slice(0, 300)}`;
747
830
  }
748
831
  return call;
@@ -753,7 +836,7 @@ function normalize(data, id) {
753
836
  const message = choice?.message ?? {};
754
837
 
755
838
  const toolCalls = (message.tool_calls ?? []).map((c) =>
756
- readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments })
839
+ readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments, cutOff: choice?.finish_reason === 'length' })
757
840
  );
758
841
 
759
842
  const u = data?.usage ?? {};
@@ -0,0 +1,269 @@
1
+ /**
2
+ * stuck.js — noticing when the model is going round in circles.
3
+ *
4
+ * Measured on a real build (a tip calculator, 84 model steps): six edits whose
5
+ * old_string and new_string were identical, the same build failing three times
6
+ * running on the same two type errors, and thirty reads of files whose current
7
+ * text was already in the conversation. Every one of those is a whole round
8
+ * trip, and a small model that has started repeating itself rarely stops on
9
+ * its own — the error it keeps getting says what is wrong, but nothing says
10
+ * "you have tried exactly this before".
11
+ *
12
+ * So each finished tool call becomes an event, and the recent events are
13
+ * checked for four patterns:
14
+ *
15
+ * repeat the same call, with the same arguments, failing the same way
16
+ * identical edits refused because old_string and new_string are the same
17
+ * reread reading a file whose unchanged text is still in view
18
+ * build a build failing with the same error text
19
+ *
20
+ * The first time a pattern reaches its threshold, the result that completed it
21
+ * carries a firm, specific note: what was repeated, the error, what to do
22
+ * instead. If the same pattern turns up again after that note, a nudge has not
23
+ * worked, and the caller hands the turn to another model.
24
+ *
25
+ * Everything here is pure: events in, verdicts out. The loop owns the side
26
+ * effects — appending the text and switching the model.
27
+ */
28
+
29
+ import { createHash } from 'node:crypto';
30
+
31
+ /** How many recent tool calls the patterns are looked for in. */
32
+ export const WINDOW = 20;
33
+
34
+ /** Occurrences inside the window that make a hit. */
35
+ export const THRESHOLDS = { repeat: 3, identical: 2, reread: 1, build: 3 };
36
+
37
+ const hash = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
38
+
39
+ /** Argument values compared the way a person would: whitespace runs do not make a call different. */
40
+ function stable(value) {
41
+ if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
42
+ if (Array.isArray(value)) return value.map(stable);
43
+ if (value && typeof value === 'object') {
44
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /** Name and arguments, reduced to one short string. */
50
+ export function signature(call) {
51
+ return `${call?.name}:${hash(JSON.stringify(stable(call?.args ?? {})))}`;
52
+ }
53
+
54
+ /** Commands whose job is to build or type-check the project. */
55
+ const BUILD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b|\btsc\b(?![^&|;]*--watch)/i;
56
+
57
+ export const isBuild = (command) => BUILD.test(String(command ?? ''));
58
+
59
+ const ERROR_LINE = /\berror\b|Error:|Module not found|Can't resolve|Type error|is not defined|Unterminated/i;
60
+ const GENERIC = /^(?:>\s*)?Build error occurred|Failed to (?:type check|compile)|build worker exited|exiting the build|^exit code:|^What to do:|^- |build failed with \d+ errors?|^Import trace|^\d+ errors? found/i;
61
+
62
+ /**
63
+ * The part of a build's output that names what is wrong, with the noise that
64
+ * changes from run to run — timings, digests — taken out, so two failures on
65
+ * the same errors compare equal.
66
+ */
67
+ export function buildErrors(output) {
68
+ const clean = (l) => l
69
+ .replace(/\b\d+(?:\.\d+)?\s?m?s\b/g, '')
70
+ .replace(/digest: '[^']*'/g, '')
71
+ .replace(/\s+/g, ' ')
72
+ .trim();
73
+ const lines = String(output ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
74
+ const errors = [...new Set(lines.filter((l) => ERROR_LINE.test(l) && !GENERIC.test(l)).map(clean))];
75
+ return (errors.length ? errors.slice(0, 12) : lines.slice(-5).map(clean)).join('\n');
76
+ }
77
+
78
+ /**
79
+ * One finished call as the detector sees it.
80
+ *
81
+ * @param {object} call { name, args }
82
+ * @param {object} outcome
83
+ * @param {object} [outcome.out] a tool result ({ content, exitCode? })
84
+ * @param {object} [outcome.err] a ToolFailure
85
+ * @param {string[]} [outcome.rereads] files this call re-read while their text was still in view
86
+ */
87
+ export function eventFor(call, { out, err, rereads = [] } = {}) {
88
+ const args = call?.args ?? {};
89
+ const event = { tool: call?.name, sig: signature(call), failed: false, rereads: [...rereads] };
90
+ event.path = args.path ?? args.files?.[0]?.path ?? null;
91
+
92
+ if (err) {
93
+ event.failed = true;
94
+ event.kind = err.kind ?? 'error';
95
+ event.error = String(err.failed ?? err.message ?? err);
96
+ event.identical = event.kind === 'bad_args' && /old_string and new_string are identical/.test(event.error);
97
+ return event;
98
+ }
99
+
100
+ if (call?.name === 'run_command' && out && out.exitCode !== undefined && out.exitCode !== 0) {
101
+ const command = String(args.command ?? '');
102
+ event.failed = true;
103
+ event.kind = `exit ${out.exitCode}`;
104
+ event.command = command;
105
+ if (isBuild(command)) {
106
+ event.build = true;
107
+ event.error = buildErrors(out.content);
108
+ } else {
109
+ event.error = buildErrors(out.content).slice(0, 400);
110
+ }
111
+ }
112
+ return event;
113
+ }
114
+
115
+ /**
116
+ * Does the latest event complete a pattern? Returns the hit, or null.
117
+ *
118
+ * Only the newest event can complete one: every earlier event was checked when
119
+ * it arrived, so looking again would report the same hit twice.
120
+ */
121
+ export function detect(events, { window = WINDOW, thresholds = THRESHOLDS } = {}) {
122
+ const recent = events.slice(-window);
123
+ const last = recent[recent.length - 1];
124
+ if (!last) return null;
125
+
126
+ if (last.identical) {
127
+ const count = recent.filter((e) => e.identical).length;
128
+ return count >= thresholds.identical
129
+ ? { pattern: 'identical', key: 'identical', count, tool: last.tool, path: last.path, error: last.error }
130
+ : null;
131
+ }
132
+
133
+ if (last.build) {
134
+ const count = recent.filter((e) => e.build && e.error === last.error).length;
135
+ return count >= thresholds.build
136
+ ? { pattern: 'build', key: `build:${hash(last.error)}`, count, command: last.command, error: last.error }
137
+ : null;
138
+ }
139
+
140
+ if (last.rereads?.length) {
141
+ const count = recent.filter((e) => e.rereads?.length).length;
142
+ return count >= thresholds.reread
143
+ ? { pattern: 'reread', key: 'reread', count, paths: last.rereads }
144
+ : null;
145
+ }
146
+
147
+ if (last.failed) {
148
+ const same = (e) => e.failed && !e.identical && !e.build && e.sig === last.sig && e.kind === last.kind && e.error === last.error;
149
+ const count = recent.filter(same).length;
150
+ return count >= thresholds.repeat
151
+ ? {
152
+ pattern: 'repeat', key: `repeat:${last.sig}:${last.kind}:${hash(last.error)}`, count,
153
+ tool: last.tool, kind: last.kind, error: last.error, path: last.path, command: last.command,
154
+ }
155
+ : null;
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ /** What each pattern is, as a few words for the user and for the model-switch note. */
162
+ export function describeHit(hit) {
163
+ switch (hit.pattern) {
164
+ case 'identical': return 'making edits that change nothing';
165
+ case 'build': return 'rebuilding on the same errors';
166
+ case 'reread': return 're-reading files it already has';
167
+ default: return `repeating a failing ${hit.tool}`;
168
+ }
169
+ }
170
+
171
+ const nth = (n) => `${n}${n % 10 === 1 && n % 100 !== 11 ? 'st' : n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th'}`;
172
+
173
+ /** What to do instead, by the kind of failure being repeated. */
174
+ function adviceFor(hit) {
175
+ if (hit.tool === 'run_command') {
176
+ return 'Read the output above: it names the problem. Change the cause - the code, the ' +
177
+ 'command, or its cwd - before running it again, or take a different route.';
178
+ }
179
+ switch (hit.kind) {
180
+ case 'no_match':
181
+ return `Read ${hit.path ?? 'the file'} once, copy old_string from that result exactly as it ` +
182
+ 'stands, without the line-number gutter - or, if the change is large, rewrite the file with write_file.';
183
+ case 'ambiguous':
184
+ return 'Add the lines around it to old_string until it matches one place only.';
185
+ case 'not_found':
186
+ return 'That path does not exist. Find the right one with glob or list_dir first.';
187
+ case 'bad_args':
188
+ return 'Fix exactly the argument the error names before calling it again.';
189
+ default:
190
+ return 'Change what the error points at, or take a different route.';
191
+ }
192
+ }
193
+
194
+ /** The note appended to the result that completed a hit. Empty when the result already says it. */
195
+ export function nudge(hit, { switched = false } = {}) {
196
+ const lead = switched
197
+ ? 'ucode has handed this turn to another model, because the last one kept ' +
198
+ `${describeHit(hit)} after being told to stop. `
199
+ : '';
200
+
201
+ switch (hit.pattern) {
202
+ case 'identical':
203
+ return `${lead}STOP - that is ${hit.count} edits in a row whose old_string and new_string are ` +
204
+ 'identical. An edit like that changes nothing, so it is refused every time. If ' +
205
+ `${hit.path ?? 'the file'} already says what you want, that part is finished: move on to the ` +
206
+ 'next thing. If it does not, put the text you actually want in new_string.';
207
+ case 'build':
208
+ return `${lead}STOP - \`${hit.command}\` has now failed ${hit.count} times with the same errors:\n` +
209
+ `${hit.error}\n` +
210
+ 'Building again without changing the code those lines point at fails the same way, and each ' +
211
+ 'build takes most of a minute. Fix every error listed - in one pass, with edit_files or ' +
212
+ 'multi_edit - then build once.';
213
+ case 'reread':
214
+ // The result itself already says the text was not sent again and why;
215
+ // only a model switch has anything to add.
216
+ return switched
217
+ ? `${lead}Work from the file text already in this conversation instead of reading it again.`
218
+ : '';
219
+ default:
220
+ return `${lead}STOP - this is the ${nth(hit.count)} time ${hit.tool} has been called with exactly ` +
221
+ `these arguments, and it failed the same way every time (${hit.kind}: ${oneLine(hit.error)}). ` +
222
+ `Calling it again will fail again. ${adviceFor(hit)}`;
223
+ }
224
+ }
225
+
226
+ const oneLine = (s) => {
227
+ const line = String(s ?? '').split('\n')[0];
228
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
229
+ };
230
+
231
+ /**
232
+ * The detector with memory: which hits have already had their nudge.
233
+ *
234
+ * observe() returns null, a nudge, or a switch. A nudge is remembered for as
235
+ * long as it is inside the window; the same pattern turning up again while it
236
+ * is remembered means the nudge did not work. After a switch the slate is
237
+ * wiped, so the new model gets a nudge of its own before any further switch.
238
+ */
239
+ export class StuckWatch {
240
+ constructor({ window = WINDOW, thresholds = THRESHOLDS } = {}) {
241
+ this.window = window;
242
+ this.thresholds = thresholds;
243
+ this.events = [];
244
+ this.seq = 0;
245
+ this.nudged = new Map(); // hit key -> seq of the event that was nudged
246
+ }
247
+
248
+ observe(event) {
249
+ this.seq++;
250
+ this.events.push({ ...event, seq: this.seq });
251
+ if (this.events.length > this.window * 2) this.events.splice(0, this.events.length - this.window);
252
+
253
+ const hit = detect(this.events, { window: this.window, thresholds: this.thresholds });
254
+ if (!hit) return null;
255
+
256
+ const at = this.nudged.get(hit.key);
257
+ if (at !== undefined && this.seq - at < this.window) {
258
+ this.reset();
259
+ return { action: 'switch', hit, text: nudge(hit, { switched: true }) };
260
+ }
261
+ this.nudged.set(hit.key, this.seq);
262
+ return { action: 'nudge', hit, text: nudge(hit) };
263
+ }
264
+
265
+ reset() {
266
+ this.events = [];
267
+ this.nudged.clear();
268
+ }
269
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * tests.js — running the tests a change actually affects.
3
+ *
4
+ * A project's whole suite is too slow to run after every edit, and running
5
+ * nothing means the model learns a change was wrong from the user rather than
6
+ * from the code. Both vitest and jest can be asked which tests reach a given
7
+ * file and run only those, which is usually a second or two.
8
+ *
9
+ * Nothing is installed to make this work. If the project has no test runner,
10
+ * or has one that cannot answer "which tests cover this file", the checks
11
+ * stay as they were: this adds a signal where one is available, and is silent
12
+ * where it is not.
13
+ */
14
+
15
+ import { promises as fs } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const has = (deps, name) => Boolean(deps[name]);
19
+
20
+ /** A file that is itself a test, and so is its own related test. */
21
+ export const isTestFile = (rel) =>
22
+ /(?:^|[\\/])(?:__tests__|tests?)[\\/]/.test(rel) || /\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(rel) ||
23
+ /(?:^|[\\/])test_[^\\/]+\.py$/i.test(rel) || /_test\.py$/i.test(rel);
24
+
25
+ /**
26
+ * Which runner this project uses, read from its package.json. Only runners
27
+ * that can select tests by the file they cover are worth naming here.
28
+ */
29
+ export async function testRunnerFor(dir) {
30
+ const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8').catch(() => null);
31
+ if (raw) {
32
+ let pkg;
33
+ try { pkg = JSON.parse(raw); } catch { pkg = null; }
34
+ if (pkg) {
35
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
36
+ const script = String(pkg.scripts?.test ?? '');
37
+ if (has(deps, 'vitest') || /\bvitest\b/.test(script)) return 'vitest';
38
+ if (has(deps, 'jest') || /\bjest\b/.test(script)) return 'jest';
39
+ }
40
+ }
41
+ const py = await Promise.all(
42
+ ['pytest.ini', 'pyproject.toml', 'setup.cfg', 'tox.ini'].map((f) =>
43
+ fs.access(path.join(dir, f)).then(() => true, () => false))
44
+ );
45
+ return py.some(Boolean) ? 'pytest' : null;
46
+ }
47
+
48
+ /** Quote a path for a shell, and use forward slashes so Windows agrees. */
49
+ const arg = (p) => `"${p.replace(/\\/g, '/')}"`;
50
+
51
+ /**
52
+ * The command that runs only the tests reaching these files, or null when
53
+ * this runner cannot narrow it down.
54
+ *
55
+ * pytest has no "which tests cover this file", so it gets the test files from
56
+ * among those changed — enough to catch a test edited into failing, and
57
+ * honest about being less than the others.
58
+ */
59
+ export function relatedCommand(runner, files) {
60
+ const list = files.filter(Boolean);
61
+ if (!list.length) return null;
62
+
63
+ if (runner === 'vitest') {
64
+ return `npx --no-install vitest related --run --passWithNoTests ${list.map(arg).join(' ')}`;
65
+ }
66
+ if (runner === 'jest') {
67
+ return `npx --no-install jest --findRelatedTests --passWithNoTests --silent ${list.map(arg).join(' ')}`;
68
+ }
69
+ if (runner === 'pytest') {
70
+ const tests = list.filter(isTestFile);
71
+ if (!tests.length) return null;
72
+ return `python -m pytest -q ${tests.map(arg).join(' ')}`;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** The failing part of a test run, kept to what a model can act on. */
78
+ export function summariseFailures(runner, output, limit = 40) {
79
+ const lines = String(output ?? '').split('\n');
80
+ const interesting = lines.filter((l) =>
81
+ /^\s*(?:✗|×|✕|FAIL|●|E\s|_{3,}|AssertionError|Expected|Received|at\s)/.test(l) ||
82
+ /\b\d+ failed\b/i.test(l) || /^FAILED /.test(l)
83
+ );
84
+ const kept = (interesting.length ? interesting : lines.filter((l) => l.trim())).slice(0, limit);
85
+ return kept.join('\n');
86
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * blocks.js — pieces of an app that are already right.
3
+ *
4
+ * A model writing a table from scratch writes a passable one: no empty state,
5
+ * no sort, numbers left-aligned, and nothing that works on a phone. It is not
6
+ * that it cannot do better, it is that doing better costs steps and attention
7
+ * that belong to the thing being built.
8
+ *
9
+ * These are the parts every app needs, written once and carefully: a shell, a
10
+ * page header, an empty state, a table, a row of stats. They are copied into
11
+ * the app as ordinary source files for the model to edit, not imported from a
12
+ * library it cannot change.
13
+ */
14
+
15
+ import { promises as fs } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { ToolFailure } from '../core/failure.js';
19
+ import { resolveIn, guard, result } from './shared.js';
20
+
21
+ const BLOCKS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'blocks');
22
+
23
+ /** What each block is for, and what it needs to be handed. */
24
+ export const CATALOGUE = {
25
+ 'app-shell': {
26
+ what: 'The frame every page sits in: sidebar on desktop, the same nav behind a button on a phone.',
27
+ exports: 'AppShell',
28
+ use: '<AppShell title="Stride" nav={[{ href: "/", label: "Home" }]} current="/">…</AppShell>',
29
+ },
30
+ 'page-header': {
31
+ what: 'The top of a page: title, a line about it, and the actions available here.',
32
+ exports: 'PageHeader',
33
+ use: '<PageHeader title="Invoices" description="Everything you have billed." actions={<Button>New</Button>} />',
34
+ },
35
+ 'empty-state': {
36
+ what: 'What a list looks like before anything is in it, with the one action that fills it.',
37
+ exports: 'EmptyState',
38
+ use: '<EmptyState title="No invoices yet" description="They will appear here." actionLabel="New invoice" />',
39
+ },
40
+ 'data-table': {
41
+ what: 'A table you can search and sort, with an empty state and numbers aligned right.',
42
+ exports: 'DataTable, Column',
43
+ use: '<DataTable rows={rows} columns={[{ key: "name", header: "Name" }, { key: "total", header: "Total", numeric: true }]} />',
44
+ },
45
+ 'stat-cards': {
46
+ what: 'The row of numbers at the top of a dashboard, each with what it is measured against.',
47
+ exports: 'StatCards, Stat',
48
+ use: '<StatCards stats={[{ label: "Revenue", value: "£12,400", change: 8 }]} />',
49
+ },
50
+ };
51
+
52
+ export const BLOCK_NAMES = Object.keys(CATALOGUE);
53
+
54
+ const listing = () =>
55
+ BLOCK_NAMES.map((name) => ` ${name} — ${CATALOGUE[name].what}`).join('\n');
56
+
57
+ /**
58
+ * Copy a block into an app, or list what there is. The file lands in
59
+ * src/components/blocks/ and is the app's to edit from then on.
60
+ */
61
+ export async function addBlock({ name, folder = '.' }) {
62
+ const wanted = String(name ?? '').trim();
63
+
64
+ if (!wanted) {
65
+ return result(
66
+ `Blocks you can add, with add_block({ name, folder }):\n\n${listing()}\n\n` +
67
+ 'Each one is copied into the app as a source file you can then edit.',
68
+ `${BLOCK_NAMES.length} blocks`
69
+ );
70
+ }
71
+
72
+ if (!CATALOGUE[wanted]) {
73
+ throw new ToolFailure({
74
+ kind: 'no_such_block',
75
+ attempted: `adding the "${wanted}" block`,
76
+ failed: `There is no block called "${wanted}".`,
77
+ fix: `Pick one of: ${BLOCK_NAMES.join(', ')}. Call add_block with no name to see what each is for.`,
78
+ });
79
+ }
80
+
81
+ const target = resolveIn(folder || '.', 'add_block', 'folder');
82
+ await guard(target, `add the ${wanted} block to ${target.abs}`);
83
+
84
+ const source = await fs.readFile(path.join(BLOCKS, `${wanted}.tsx`), 'utf8').catch(() => null);
85
+ if (source === null) {
86
+ throw new ToolFailure({
87
+ kind: 'block_missing',
88
+ attempted: `adding the "${wanted}" block`,
89
+ failed: `The ${wanted} block is listed but its file is not installed.`,
90
+ fix: 'Write the component by hand, or reinstall ucode.',
91
+ });
92
+ }
93
+
94
+ const rel = path.join('src', 'components', 'blocks', `${wanted}.tsx`);
95
+ const dest = path.join(target.abs, rel);
96
+
97
+ if (await fs.stat(dest).catch(() => null)) {
98
+ return result(
99
+ `${rel} is already in ${target.show}; it has been left as it is so your edits survive.\n` +
100
+ `Import: import { ${CATALOGUE[wanted].exports.split(',')[0].trim()} } from "@/components/blocks/${wanted}";`,
101
+ 'already there'
102
+ );
103
+ }
104
+
105
+ await fs.mkdir(path.dirname(dest), { recursive: true });
106
+ await fs.writeFile(dest, source, 'utf8');
107
+
108
+ const first = CATALOGUE[wanted].exports.split(',')[0].trim();
109
+ return result(
110
+ `Added ${rel} to ${target.show}.\n\n` +
111
+ `import { ${CATALOGUE[wanted].exports} } from "@/components/blocks/${wanted}";\n\n` +
112
+ `${CATALOGUE[wanted].use}\n\n` +
113
+ `It is an ordinary file now — edit it to suit the app rather than working around it. ` +
114
+ `It uses the shadcn components already in the starter, so nothing needs installing.`,
115
+ `${first} added`
116
+ );
117
+ }