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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills.js — instruction packs, disclosed progressively.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a folder holding a SKILL.md: YAML frontmatter, then the
|
|
5
|
+
* instructions. Only names and one-line descriptions go into the system
|
|
6
|
+
* prompt; a body is pulled into the conversation the moment it is wanted. The
|
|
7
|
+
* base prompt therefore stays the same size whether there are three skills or
|
|
8
|
+
* thirty.
|
|
9
|
+
*
|
|
10
|
+
* Two ways a body gets loaded:
|
|
11
|
+
*
|
|
12
|
+
* the model asks for it, with the load_skill tool; or
|
|
13
|
+
* the frontmatter says `auto:` and one of those words is in the request,
|
|
14
|
+
* in which case it is already loaded before the model takes its first step.
|
|
15
|
+
*
|
|
16
|
+
* The second one exists because the first one is a judgement call, and a model
|
|
17
|
+
* in a hurry to be helpful skips judgement calls. Design quality is not
|
|
18
|
+
* something to find out was skipped after the app is built.
|
|
19
|
+
*
|
|
20
|
+
* Skills are read from two places, the project first so a repo can override a
|
|
21
|
+
* built-in of the same name:
|
|
22
|
+
* <cwd>/.ucode/skills/<name>/SKILL.md
|
|
23
|
+
* <install dir>/skills/<name>/SKILL.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { promises as fs } from 'node:fs';
|
|
27
|
+
import path, { dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
|
|
32
|
+
export const BUILTIN_DIR = path.join(HERE, '..', '..', 'skills');
|
|
33
|
+
|
|
34
|
+
export function projectDir(cwd = process.cwd()) {
|
|
35
|
+
return path.join(cwd, '.ucode', 'skills');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Enough YAML for `key: value`, quoted or bare. Skills are not config files. */
|
|
39
|
+
export function parseSkill(text, source) {
|
|
40
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text.replace(/^/, ''));
|
|
41
|
+
if (!match) {
|
|
42
|
+
return { error: `${source} has no frontmatter — it must start with a --- line.` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const meta = {};
|
|
46
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
47
|
+
const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
48
|
+
if (!kv) continue;
|
|
49
|
+
let value = kv[2].trim();
|
|
50
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
51
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
52
|
+
value = value.slice(1, -1);
|
|
53
|
+
}
|
|
54
|
+
meta[kv[1]] = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!meta.name) return { error: `${source} frontmatter has no "name".` };
|
|
58
|
+
if (!meta.description) return { error: `${source} frontmatter has no "description".` };
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
skill: {
|
|
62
|
+
name: meta.name,
|
|
63
|
+
description: meta.description,
|
|
64
|
+
// Words that pull this skill in before the model has said anything.
|
|
65
|
+
triggers: (meta.auto ?? '')
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((t) => t.trim().toLowerCase())
|
|
68
|
+
.filter(Boolean),
|
|
69
|
+
body: match[2].trim(),
|
|
70
|
+
source,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readDir(dir) {
|
|
76
|
+
const skills = [];
|
|
77
|
+
const problems = [];
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return { skills, problems }; // no skills directory is a normal state
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory()) continue;
|
|
88
|
+
const file = path.join(dir, entry.name, 'SKILL.md');
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = await fs.readFile(file, 'utf8');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err.code !== 'ENOENT') problems.push(`${file} could not be read: ${err.message}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const { skill, error } = parseSkill(text, file);
|
|
97
|
+
if (error) problems.push(error);
|
|
98
|
+
else skills.push(skill);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { skills, problems };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every available skill, project ones shadowing built-ins by name.
|
|
106
|
+
* `problems` rides along non-enumerably for the UI to report.
|
|
107
|
+
*/
|
|
108
|
+
export async function loadSkills({ cwd = process.cwd() } = {}) {
|
|
109
|
+
const builtin = await readDir(BUILTIN_DIR);
|
|
110
|
+
const project = await readDir(projectDir(cwd));
|
|
111
|
+
|
|
112
|
+
const byName = new Map();
|
|
113
|
+
for (const s of builtin.skills) byName.set(s.name, s);
|
|
114
|
+
for (const s of project.skills) byName.set(s.name, s);
|
|
115
|
+
|
|
116
|
+
const skills = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
117
|
+
Object.defineProperty(skills, 'problems', {
|
|
118
|
+
value: [...builtin.problems, ...project.problems],
|
|
119
|
+
enumerable: false,
|
|
120
|
+
});
|
|
121
|
+
return skills;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The system-prompt block: names and descriptions, never bodies. */
|
|
125
|
+
export function catalogue(skills) {
|
|
126
|
+
if (!skills.length) return '';
|
|
127
|
+
return skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function findSkill(skills, name) {
|
|
131
|
+
const wanted = String(name ?? '').trim().toLowerCase();
|
|
132
|
+
return skills.find((s) => s.name.toLowerCase() === wanted);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Which skills this request should arrive with already loaded.
|
|
137
|
+
*
|
|
138
|
+
* A trigger matches on a word boundary, so "app" fires on "build me an app"
|
|
139
|
+
* but not on "happy". Multi-word triggers are matched as phrases.
|
|
140
|
+
*/
|
|
141
|
+
export function autoLoadFor(skills, text) {
|
|
142
|
+
const request = String(text ?? '').toLowerCase();
|
|
143
|
+
if (!request.trim()) return [];
|
|
144
|
+
|
|
145
|
+
return skills.filter((skill) =>
|
|
146
|
+
skill.triggers.some((trigger) => {
|
|
147
|
+
const escaped = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
148
|
+
return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(request);
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** How a body enters the conversation. */
|
|
154
|
+
export function skillMessage(skill, { automatic = false } = {}) {
|
|
155
|
+
const why = automatic
|
|
156
|
+
? `The "${skill.name}" skill was loaded automatically because this request is the kind it covers.`
|
|
157
|
+
: `The "${skill.name}" skill was loaded for this task.`;
|
|
158
|
+
return {
|
|
159
|
+
role: 'system',
|
|
160
|
+
content:
|
|
161
|
+
`${why} Follow it — it outranks your defaults, and it is not optional.\n\n` +
|
|
162
|
+
`--- BEGIN SKILL: ${skill.name} ---\n${skill.body}\n--- END SKILL ---`,
|
|
163
|
+
skill: skill.name,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* window.js — keeping a long conversation inside the model's context.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, both of which exist because the obvious alternative is worse:
|
|
5
|
+
*
|
|
6
|
+
* Summarize the oldest turns rather than dropping them. Dropping means the
|
|
7
|
+
* agent forgets a decision it made an hour ago and quietly contradicts it.
|
|
8
|
+
*
|
|
9
|
+
* Never cut between a tool call and its result. A result with no call above
|
|
10
|
+
* it is unreadable to the model and to anyone debugging the transcript.
|
|
11
|
+
*
|
|
12
|
+
* Only what gets sent is affected. history.js keeps the full record on disk
|
|
13
|
+
* whatever happens here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { estimateConversation } from './provider.js';
|
|
17
|
+
|
|
18
|
+
/** Start folding once the conversation passes this share of the window. */
|
|
19
|
+
export const FOLD_AT = 0.75;
|
|
20
|
+
|
|
21
|
+
/** After folding, the verbatim tail may occupy this share of the window. */
|
|
22
|
+
export const KEEP = 0.4;
|
|
23
|
+
|
|
24
|
+
export function usage(messages, limit) {
|
|
25
|
+
const used = estimateConversation(messages);
|
|
26
|
+
return {
|
|
27
|
+
used,
|
|
28
|
+
limit,
|
|
29
|
+
percent: limit > 0 ? Math.min(100, (used / limit) * 100) : 0,
|
|
30
|
+
left: Math.max(0, limit - used),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function tooBig(messages, limit) {
|
|
35
|
+
return usage(messages, limit).used > limit * FOLD_AT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where to cut so the kept tail stands on its own.
|
|
40
|
+
*
|
|
41
|
+
* Walks backwards accumulating messages until the budget runs out, then nudges
|
|
42
|
+
* the boundary forward past any tool result whose call would have been folded
|
|
43
|
+
* away.
|
|
44
|
+
*/
|
|
45
|
+
function cutPoint(messages, budget) {
|
|
46
|
+
let cut = messages.length;
|
|
47
|
+
let left = budget;
|
|
48
|
+
|
|
49
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
50
|
+
const cost = estimateConversation([messages[i]]);
|
|
51
|
+
if (left - cost < 0 && cut < messages.length) break;
|
|
52
|
+
left -= cost;
|
|
53
|
+
cut = i;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
while (cut < messages.length && messages[cut].role === 'tool') cut++;
|
|
57
|
+
|
|
58
|
+
// Whatever else happens, the most recent exchange survives intact.
|
|
59
|
+
if (cut >= messages.length) cut = Math.max(0, messages.length - 1);
|
|
60
|
+
return cut;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fold the older half of a conversation into a summary, if it needs it.
|
|
65
|
+
*
|
|
66
|
+
* @param {Array} messages
|
|
67
|
+
* @param {object} o
|
|
68
|
+
* @param {number} o.limit token budget
|
|
69
|
+
* @param {Function} o.summarize async (older) => string
|
|
70
|
+
*/
|
|
71
|
+
export async function fold(messages, { limit, summarize }) {
|
|
72
|
+
if (!tooBig(messages, limit)) return { messages, folded: false };
|
|
73
|
+
|
|
74
|
+
const cut = cutPoint(messages, limit * KEEP);
|
|
75
|
+
const older = messages.slice(0, cut);
|
|
76
|
+
const recent = messages.slice(cut);
|
|
77
|
+
|
|
78
|
+
// Nothing old enough to fold — the tail on its own is already oversized.
|
|
79
|
+
if (older.length === 0) return { messages, folded: false };
|
|
80
|
+
|
|
81
|
+
const summary = await summarize(older);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
folded: true,
|
|
85
|
+
summary,
|
|
86
|
+
droppedCount: older.length,
|
|
87
|
+
messages: [
|
|
88
|
+
{
|
|
89
|
+
role: 'system',
|
|
90
|
+
content:
|
|
91
|
+
`Summary of the earlier part of this conversation. ${older.length} messages ` +
|
|
92
|
+
`were folded away to stay inside the context window.\n\n${summary}\n\n` +
|
|
93
|
+
'Treat all of that as settled context. Everything after this point is verbatim.',
|
|
94
|
+
folded: true,
|
|
95
|
+
},
|
|
96
|
+
...recent,
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What the summarizer is asked to do. */
|
|
102
|
+
export const SUMMARY_PROMPT =
|
|
103
|
+
'Summarize this conversation so another engineer could pick it up mid-task with ' +
|
|
104
|
+
'nothing else to go on. Keep, in this order: (1) what the user is trying to achieve, ' +
|
|
105
|
+
'(2) which files were read or changed and what is in them, (3) decisions taken and the ' +
|
|
106
|
+
'reasoning, (4) commands run and what they printed, (5) what is still unfinished. ' +
|
|
107
|
+
'Name exact paths, functions and error messages. No preamble, no commentary.';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Flatten the messages being folded into plain text for the summarizer.
|
|
111
|
+
* Tool traffic is included but trimmed — that a tool ran and roughly what it
|
|
112
|
+
* returned matters; its exact bytes almost never do.
|
|
113
|
+
*/
|
|
114
|
+
export function forSummary(messages) {
|
|
115
|
+
return messages
|
|
116
|
+
.map((m) => {
|
|
117
|
+
if (m.role === 'tool') return `[${m.name} returned]\n${(m.content || '').slice(0, 300)}`;
|
|
118
|
+
if (m.role === 'assistant') {
|
|
119
|
+
const calls = (m.toolCalls || [])
|
|
120
|
+
.map((c) => `[called ${c.name}(${JSON.stringify(c.args).slice(0, 200)})]`)
|
|
121
|
+
.join('\n');
|
|
122
|
+
return `assistant: ${m.content || ''}\n${calls}`.trim();
|
|
123
|
+
}
|
|
124
|
+
return `${m.role}: ${m.content || ''}`;
|
|
125
|
+
})
|
|
126
|
+
.join('\n\n');
|
|
127
|
+
}
|