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.
- package/README.md +399 -327
- package/package.json +6 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/livelog.js +113 -0
- package/src/core/loop.js +2105 -1659
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/core/tests.js +86 -0
- package/src/tools/blocks.js +117 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/cache.js +105 -0
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +634 -495
- package/src/tools/rename.js +157 -0
- package/src/tools/scaffold.js +85 -6
- package/src/tools/shell.js +799 -701
- package/src/tools/symbols.js +218 -0
- package/src/tools/types.js +179 -0
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/src/ui/theme.js +5 -1
- package/templates/blocks/app-shell.tsx +81 -0
- package/templates/blocks/data-table.tsx +117 -0
- package/templates/blocks/empty-state.tsx +41 -0
- package/templates/blocks/page-header.tsx +27 -0
- package/templates/blocks/stat-cards.tsx +46 -0
- package/templates/next-shadcn/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- package/ucode.js +8 -1
package/src/core/provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
? `
|
|
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
|
-
?
|
|
411
|
-
'your account
|
|
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
|
-
|
|
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
|
+
}
|