codeply-cli 0.4.1 → 0.5.1
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/lib/agent.mjs +12 -4
- package/lib/bots.js +1002 -0
- package/lib/cloud-browser.mjs +143 -0
- package/lib/cloud.mjs +443 -95
- package/lib/native-tools.mjs +2 -1
- package/lib/oauth-connectors.js +51 -9
- package/lib/tools.mjs +40 -12
- package/package.json +1 -1
package/lib/bots.js
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bots: named agents with one clear job, a tone, a memory that grows, and a
|
|
3
|
+
* soft sticker avatar.
|
|
4
|
+
*
|
|
5
|
+
* A bot is a JSON file in ~/.codeply/bots/<id>.json. When the user picks a bot
|
|
6
|
+
* for a chat, buildBotPrompt() is added to that run's system prompt. After
|
|
7
|
+
* each reply, learnFromTurn() asks the model for at most 3 durable facts to
|
|
8
|
+
* remember, so the bot gets better at working with this user over time.
|
|
9
|
+
* After a run that used tools, reflectOnRun() looks at what the bot did and
|
|
10
|
+
* keeps experience (lessons, playbooks, tool tips, open threads), so the bot
|
|
11
|
+
* also gets better at the work itself.
|
|
12
|
+
*
|
|
13
|
+
* Bots talk to each other through the ask_bot tool. delegate() runs the asked
|
|
14
|
+
* bot as an isolated sub-run (its own prompt, only the task text it was
|
|
15
|
+
* given) behind one global lock, so only ONE agent ever runs at a time: the
|
|
16
|
+
* caller waits while the helper works, then gets a structured result back.
|
|
17
|
+
* No self-calls, no cycles, at most 2 levels deep.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here talks to a model directly: the host passes a chat function in
|
|
20
|
+
* (ai.js chatJson in the app, a fake in the tests).
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const MAX_MEMORY = 60;
|
|
27
|
+
const MAX_FACTS_PER_TURN = 3;
|
|
28
|
+
const MAX_DEPTH = 2;
|
|
29
|
+
// Experience memory (MUSE style: learned from the bot's own work, not about the user).
|
|
30
|
+
const MAX_LESSONS = 12;
|
|
31
|
+
const MAX_PLAYBOOKS = 40;
|
|
32
|
+
const MAX_TOOL_TIPS = 15;
|
|
33
|
+
const MAX_OPEN_THREADS = 5;
|
|
34
|
+
const PLAYBOOKS_IN_FULL = 2;
|
|
35
|
+
|
|
36
|
+
let dirOverride = null;
|
|
37
|
+
function botsDir() {
|
|
38
|
+
return dirOverride || process.env.CODEPLY_BOTS_DIR || path.join(os.homedir(), '.codeply', 'bots');
|
|
39
|
+
}
|
|
40
|
+
/** Tests (and only tests) point the store somewhere else. */
|
|
41
|
+
function setBotsDir(dir) { dirOverride = dir || null; }
|
|
42
|
+
|
|
43
|
+
// ─── Vocabulary ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const ROLES = { orchestrator: 'Orchestrator', specialist: 'Specialist' };
|
|
46
|
+
|
|
47
|
+
const TONES = {
|
|
48
|
+
friendly: { label: 'Friendly', text: 'Warm and encouraging, plain words, a little personality.' },
|
|
49
|
+
concise: { label: 'Concise', text: 'Short and to the point. Lead with the answer, skip the filler.' },
|
|
50
|
+
professional: { label: 'Professional', text: 'Calm, precise and polished, like a senior colleague.' },
|
|
51
|
+
playful: { label: 'Playful', text: 'Light, upbeat and a bit funny, without getting in the way of the work.' },
|
|
52
|
+
direct: { label: 'Direct', text: 'Blunt and honest. Say what is wrong and what to do about it.' },
|
|
53
|
+
teacher: { label: 'Teacher', text: 'Patient. Explain the why in simple steps and check understanding.' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** What a bot must ask the user before doing. Each maps to engine tools. */
|
|
57
|
+
const APPROVALS = {
|
|
58
|
+
edit_files: { label: 'Editing files', tools: ['write_file', 'edit_file', 'apply_patch', 'fetch_image'] },
|
|
59
|
+
run_commands: { label: 'Running commands', tools: ['run'] },
|
|
60
|
+
send: { label: 'Sending anything (email, Slack)', tools: ['gmail_send', 'slack_post_message'] },
|
|
61
|
+
publish: { label: 'Deploying or publishing', tools: ['vercel_deploy', 'github_create_repo', 'vercel_api'] },
|
|
62
|
+
databases: { label: 'Changing databases or cloud projects', tools: ['supabase_create_project', 'supabase_delete_project', 'supabase_api', 'supabase_sql'] },
|
|
63
|
+
};
|
|
64
|
+
const ALL_APPROVALS = Object.keys(APPROVALS);
|
|
65
|
+
|
|
66
|
+
// Kept in sync with bot-avatar.js (the renderer). The engine only validates.
|
|
67
|
+
const AVATAR_KEYS = {
|
|
68
|
+
shape: ['burst9', 'burst7', 'burst12', 'flower', 'cloud', 'star', 'squircle', 'pebble', 'drop'],
|
|
69
|
+
eyes: ['pills', 'dots', 'ovals', 'sleepy', 'happy', 'sparkle', 'big'],
|
|
70
|
+
glasses: ['none', 'round', 'visor', 'monocle'],
|
|
71
|
+
accessory: ['none', 'antenna', 'halo', 'sprout', 'sparkles', 'star'],
|
|
72
|
+
mouth: ['none', 'smile', 'o', 'flat'],
|
|
73
|
+
color: ['graphite', 'green', 'blue', 'yellow', 'pink', 'orange', 'purple', 'red', 'teal', 'sky', 'lime'],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Keys from the earlier avatar sets (the first plush set, then a robot set),
|
|
77
|
+
// so bots saved before the redesign still load. Same table as LEGACY in
|
|
78
|
+
// bot-avatar.js.
|
|
79
|
+
const LEGACY_AVATAR = {
|
|
80
|
+
shape: {
|
|
81
|
+
bean: 'pebble', blob: 'pebble', round: 'squircle', pear: 'drop', heart: 'flower', frog: 'cloud', ghost: 'drop',
|
|
82
|
+
capsule: 'squircle', pill: 'pebble', hexagon: 'burst7', octagon: 'flower', chip: 'squircle', orb: 'burst12', shield: 'drop', dome: 'cloud', monitor: 'squircle',
|
|
83
|
+
},
|
|
84
|
+
eyes: {
|
|
85
|
+
diamond: 'pills', wide: 'ovals',
|
|
86
|
+
led: 'pills', pixel: 'dots', lens: 'big', visor: 'pills', arcs: 'happy', slits: 'sleepy', rings: 'ovals', plus: 'sparkle',
|
|
87
|
+
},
|
|
88
|
+
glasses: { square: 'round', sunglasses: 'visor', frames: 'round', shades: 'visor', hud: 'round' },
|
|
89
|
+
accessory: {
|
|
90
|
+
beret: 'sprout', bowtie: 'star', cap: 'antenna', headphones: 'antenna', flower: 'sprout', crown: 'halo',
|
|
91
|
+
twin: 'antenna', headset: 'antenna', propeller: 'sprout', fins: 'sparkles', badge: 'star',
|
|
92
|
+
},
|
|
93
|
+
mouth: { grin: 'smile', line: 'flat', curve: 'smile', wave: 'smile', grille: 'flat' },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function normalizeAvatar(a) {
|
|
97
|
+
a = a && typeof a === 'object' ? a : {};
|
|
98
|
+
const pick = (k, def) => {
|
|
99
|
+
if (AVATAR_KEYS[k].includes(a[k])) return a[k];
|
|
100
|
+
const old = Object.prototype.hasOwnProperty.call(LEGACY_AVATAR[k], a[k]) ? LEGACY_AVATAR[k][a[k]] : null;
|
|
101
|
+
return old && AVATAR_KEYS[k].includes(old) ? old : def;
|
|
102
|
+
};
|
|
103
|
+
const color = AVATAR_KEYS.color.includes(a.color) || /^#[0-9a-f]{6}$/i.test(String(a.color || '')) ? String(a.color) : 'graphite';
|
|
104
|
+
return {
|
|
105
|
+
shape: pick('shape', 'burst9'), eyes: pick('eyes', 'pills'), color,
|
|
106
|
+
glasses: pick('glasses', 'none'), accessory: pick('accessory', 'none'), mouth: pick('mouth', 'none'), cheeks: !!a.cheeks,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Starter templates (one per team role) ─────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const TEMPLATES = [
|
|
113
|
+
{
|
|
114
|
+
key: 'orchestrator', name: 'Orion', role: 'orchestrator',
|
|
115
|
+
specialty: 'Breaks a big goal into steps and gets the right teammate on each one',
|
|
116
|
+
instructions: 'Restate the objective in one line. Split it into a few small steps. Hand each step to the teammate whose domain fits, one at a time, with only the context they need. Read every result critically before moving on, redo or adjust when something is off, and finish with one merged answer that says what was done and what is left.',
|
|
117
|
+
tone: { preset: 'friendly', custom: '' },
|
|
118
|
+
approval: ['edit_files', 'run_commands', 'send', 'publish', 'databases'],
|
|
119
|
+
avatar: { shape: 'burst9', eyes: 'pills', color: 'graphite' },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: 'research', name: 'Vera', role: 'specialist',
|
|
123
|
+
specialty: 'Research: finds facts, docs and prior art, and cites where they came from',
|
|
124
|
+
instructions: 'Search the project and the web, read the primary source, and report what you found with links or file paths. Separate facts from guesses. Never change files.',
|
|
125
|
+
tone: { preset: 'professional', custom: '' }, sources: 'Official docs first, then the project itself, then reputable articles.',
|
|
126
|
+
approval: ['edit_files', 'run_commands', 'send', 'publish', 'databases'],
|
|
127
|
+
avatar: { shape: 'drop', eyes: 'big', color: 'blue' },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: 'outreach', name: 'Remy', role: 'specialist',
|
|
131
|
+
specialty: 'Outreach: drafts emails, posts and messages that sound like the user',
|
|
132
|
+
instructions: 'Write drafts the user can send as is: clear subject, short body, one ask. Match their voice from earlier messages. Never send anything yourself without the user saying yes.',
|
|
133
|
+
tone: { preset: 'friendly', custom: '' },
|
|
134
|
+
approval: ['edit_files', 'run_commands', 'send', 'publish', 'databases'],
|
|
135
|
+
avatar: { shape: 'cloud', eyes: 'happy', color: 'pink', cheeks: true },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: 'analysis', name: 'Quinn', role: 'specialist',
|
|
139
|
+
specialty: 'Analysis: digs into code, data and numbers and explains what they mean',
|
|
140
|
+
instructions: 'Read the real code or data before concluding anything. Show the key numbers, the reasoning, and a clear recommendation. Say how sure you are.',
|
|
141
|
+
tone: { preset: 'direct', custom: '' },
|
|
142
|
+
approval: ['edit_files', 'run_commands', 'send', 'publish', 'databases'],
|
|
143
|
+
avatar: { shape: 'squircle', eyes: 'ovals', color: 'teal', glasses: 'round' },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: 'reporting', name: 'Wren', role: 'specialist',
|
|
147
|
+
specialty: 'Reporting: turns results into short, skimmable summaries and status updates',
|
|
148
|
+
instructions: 'Lead with the outcome, then 3 to 5 bullets, then next steps. Plain words, no jargon, nothing invented.',
|
|
149
|
+
tone: { preset: 'concise', custom: '' },
|
|
150
|
+
approval: ['edit_files', 'run_commands', 'send', 'publish', 'databases'],
|
|
151
|
+
avatar: { shape: 'star', eyes: 'dots', color: 'orange', accessory: 'sprout' },
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: 'execution', name: 'Axel', role: 'specialist',
|
|
155
|
+
specialty: 'Execution: writes and changes code, runs the checks, ships the change',
|
|
156
|
+
instructions: 'Make the smallest change that does the job, in the project\'s own style. Run the relevant check after every change and report what really happened.',
|
|
157
|
+
tone: { preset: 'concise', custom: '' },
|
|
158
|
+
approval: ['run_commands', 'send', 'publish', 'databases'],
|
|
159
|
+
avatar: { shape: 'burst7', eyes: 'sleepy', color: 'red', accessory: 'antenna' },
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: 'monitoring', name: 'Juno', role: 'specialist',
|
|
163
|
+
specialty: 'Monitoring: checks that things still work and flags what changed or broke',
|
|
164
|
+
instructions: 'Run the checks, compare with what was expected, and report only what changed or failed, with the exact error. Suggest the next step, do not fix it yourself.',
|
|
165
|
+
tone: { preset: 'direct', custom: '' },
|
|
166
|
+
approval: ['edit_files', 'send', 'publish', 'databases'],
|
|
167
|
+
avatar: { shape: 'burst12', eyes: 'sparkle', color: 'purple', accessory: 'halo' },
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
// ─── Store ──────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const clip = (v, n) => String(v == null ? '' : v).replace(/\r/g, '').trim().slice(0, n);
|
|
174
|
+
|
|
175
|
+
function newId(name) {
|
|
176
|
+
const slug = String(name || 'bot').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'bot';
|
|
177
|
+
return `${slug}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cleanMemory(list) {
|
|
181
|
+
if (!Array.isArray(list)) return [];
|
|
182
|
+
return list
|
|
183
|
+
.map((m) => (typeof m === 'string' ? { fact: m, at: Date.now() } : m))
|
|
184
|
+
.filter((m) => m && typeof m.fact === 'string' && m.fact.trim())
|
|
185
|
+
.map((m) => ({ fact: clip(m.fact, 240), at: Number(m.at) || Date.now() }))
|
|
186
|
+
.slice(-MAX_MEMORY);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Any partial bot (from the UI or the model) becomes a complete, valid one. */
|
|
190
|
+
function normalizeBot(input, existing) {
|
|
191
|
+
const b = { ...(existing || {}), ...(input || {}) };
|
|
192
|
+
const tone = b.tone && typeof b.tone === 'object' ? b.tone : { preset: typeof b.tone === 'string' ? b.tone : '' };
|
|
193
|
+
return {
|
|
194
|
+
id: existing ? existing.id : (typeof b.id === 'string' && /^[a-z0-9-]{1,60}$/.test(b.id) ? b.id : newId(b.name)),
|
|
195
|
+
name: clip(b.name, 40) || 'New bot',
|
|
196
|
+
role: b.role === 'orchestrator' ? 'orchestrator' : 'specialist',
|
|
197
|
+
specialty: clip(b.specialty, 200),
|
|
198
|
+
instructions: clip(b.instructions, 4000),
|
|
199
|
+
tone: { preset: TONES[tone.preset] ? tone.preset : 'friendly', custom: clip(tone.custom, 400) },
|
|
200
|
+
sources: clip(b.sources, 2000),
|
|
201
|
+
approval: Array.isArray(b.approval) ? [...new Set(b.approval.filter((k) => APPROVALS[k]))] : ALL_APPROVALS.slice(),
|
|
202
|
+
avatar: normalizeAvatar(b.avatar),
|
|
203
|
+
// The voice it speaks with on calls (a Deepgram, Edge or Kokoro voice id; '' = pick one).
|
|
204
|
+
voice: typeof b.voice === 'string' && /^[\w.-]{0,80}$/.test(b.voice) ? b.voice : '',
|
|
205
|
+
memory: cleanMemory(b.memory),
|
|
206
|
+
// Experience (learned on the job, see reflectOnRun): lessons, playbooks, tool tips, open threads.
|
|
207
|
+
lessons: cleanLessons(b.lessons),
|
|
208
|
+
playbooks: cleanPlaybooks(b.playbooks),
|
|
209
|
+
toolTips: cleanTips(b.toolTips),
|
|
210
|
+
openThreads: cleanThreads(b.openThreads),
|
|
211
|
+
template: typeof b.template === 'string' ? b.template : undefined,
|
|
212
|
+
createdAt: existing ? existing.createdAt : (Number(b.createdAt) || Date.now()),
|
|
213
|
+
updatedAt: Date.now(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fileFor(id) {
|
|
218
|
+
if (!/^[a-z0-9-]{1,60}$/.test(String(id || ''))) throw new Error('Bad bot id.');
|
|
219
|
+
return path.join(botsDir(), `${id}.json`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function save(bot) {
|
|
223
|
+
fs.mkdirSync(botsDir(), { recursive: true });
|
|
224
|
+
const file = fileFor(bot.id);
|
|
225
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
226
|
+
fs.writeFileSync(tmp, JSON.stringify(bot, null, 2));
|
|
227
|
+
fs.renameSync(tmp, file);
|
|
228
|
+
return bot;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function listBots() {
|
|
232
|
+
let files = [];
|
|
233
|
+
try { files = fs.readdirSync(botsDir()).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
try {
|
|
237
|
+
const raw = JSON.parse(fs.readFileSync(path.join(botsDir(), f), 'utf8'));
|
|
238
|
+
const bot = normalizeBot(raw, { id: f.replace(/\.json$/, ''), createdAt: raw.createdAt });
|
|
239
|
+
bot.updatedAt = Number(raw.updatedAt) || bot.updatedAt;
|
|
240
|
+
out.push(bot);
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
return out.sort((a, b) => a.createdAt - b.createdAt);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getBot(id) {
|
|
247
|
+
if (!id) return null;
|
|
248
|
+
try { return listBots().find((b) => b.id === id) || null; } catch { return null; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** By id, exact name, then a loose name match ("the research bot", "alfred"). */
|
|
252
|
+
function findBot(nameOrId, bots) {
|
|
253
|
+
const q = String(nameOrId || '').trim().toLowerCase().replace(/^@/, '');
|
|
254
|
+
if (!q) return null;
|
|
255
|
+
const all = bots || listBots();
|
|
256
|
+
return all.find((b) => b.id === q) ||
|
|
257
|
+
all.find((b) => b.name.toLowerCase() === q) ||
|
|
258
|
+
all.find((b) => q.split(/[^a-z0-9]+/).includes(b.name.toLowerCase())) ||
|
|
259
|
+
all.find((b) => b.template && q.includes(b.template)) ||
|
|
260
|
+
null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function createBot(data) {
|
|
264
|
+
const bot = normalizeBot({ ...(data || {}), memory: (data && data.memory) || [] });
|
|
265
|
+
if (getBot(bot.id)) bot.id = newId(bot.name);
|
|
266
|
+
return save(bot);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createFromTemplate(key, overrides) {
|
|
270
|
+
const t = TEMPLATES.find((x) => x.key === key);
|
|
271
|
+
if (!t) throw new Error(`No template "${key}".`);
|
|
272
|
+
const { key: tk, ...rest } = t;
|
|
273
|
+
return createBot({ ...JSON.parse(JSON.stringify(rest)), template: tk, ...(overrides || {}) });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function updateBot(id, patch) {
|
|
277
|
+
const cur = getBot(id);
|
|
278
|
+
if (!cur) throw new Error('That bot is gone.');
|
|
279
|
+
const { id: _i, createdAt: _c, ...rest } = patch || {};
|
|
280
|
+
return save(normalizeBot(rest, cur));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function removeBot(id) {
|
|
284
|
+
try { fs.unlinkSync(fileFor(id)); return true; } catch { return false; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Memory ─────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
const factKey = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
290
|
+
|
|
291
|
+
/** Same fact, or one that contains the other: the newer wording wins. */
|
|
292
|
+
function sameFact(a, b) {
|
|
293
|
+
const x = factKey(a); const y = factKey(b);
|
|
294
|
+
if (!x || !y) return false;
|
|
295
|
+
if (x === y) return true;
|
|
296
|
+
const shorter = x.length < y.length ? x : y;
|
|
297
|
+
return shorter.length >= 12 && (x.includes(y) || y.includes(x));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function mergeMemory(memory, facts, now = Date.now()) {
|
|
301
|
+
let list = cleanMemory(memory);
|
|
302
|
+
const added = [];
|
|
303
|
+
for (const f of facts) {
|
|
304
|
+
const fact = clip(f, 240);
|
|
305
|
+
if (!fact) continue;
|
|
306
|
+
list = list.filter((m) => !sameFact(m.fact, fact));
|
|
307
|
+
list.push({ fact, at: now });
|
|
308
|
+
added.push(fact);
|
|
309
|
+
}
|
|
310
|
+
return { memory: list.slice(-MAX_MEMORY), added };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function addMemory(botId, facts) {
|
|
314
|
+
const bot = getBot(botId);
|
|
315
|
+
if (!bot) return { added: [], bot: null };
|
|
316
|
+
const r = mergeMemory(bot.memory, Array.isArray(facts) ? facts : [facts]);
|
|
317
|
+
if (!r.added.length) return { added: [], bot };
|
|
318
|
+
return { added: r.added, bot: save({ ...bot, memory: r.memory, updatedAt: Date.now() }) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function forget(botId, index) {
|
|
322
|
+
const bot = getBot(botId);
|
|
323
|
+
if (!bot) throw new Error('That bot is gone.');
|
|
324
|
+
const memory = bot.memory.slice();
|
|
325
|
+
if (index >= 0 && index < memory.length) memory.splice(index, 1);
|
|
326
|
+
return save({ ...bot, memory, updatedAt: Date.now() });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function clearMemory(botId) {
|
|
330
|
+
const bot = getBot(botId);
|
|
331
|
+
if (!bot) throw new Error('That bot is gone.');
|
|
332
|
+
return save({ ...bot, memory: [], updatedAt: Date.now() });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* After a reply: ask the model what is worth remembering. chatFn has the
|
|
337
|
+
* ai.js chatJson shape: (messages) => {success, json}. Never throws.
|
|
338
|
+
*/
|
|
339
|
+
async function learnFromTurn(bot, userText, replyText, chatFn) {
|
|
340
|
+
try {
|
|
341
|
+
if (!bot || !bot.id || typeof chatFn !== 'function') return { added: [] };
|
|
342
|
+
const user = clip(userText, 3000);
|
|
343
|
+
if (!user) return { added: [] };
|
|
344
|
+
const known = (bot.memory || []).slice(-30).map((m) => `- ${m.fact}`).join('\n') || '(nothing yet)';
|
|
345
|
+
const prompt = `You keep the long-term memory of an assistant called ${bot.name}${bot.specialty ? ` (${bot.specialty})` : ''}.
|
|
346
|
+
From the exchange below, pick at most ${MAX_FACTS_PER_TURN} durable things worth remembering for future conversations: the user's preferences, decisions, standing instructions, facts about them or their project, or feedback on how ${bot.name} should work.
|
|
347
|
+
Skip anything one-off, temporary, obvious, or already known. Never store passwords, keys or other secrets. Each item is one short sentence about the user or the work, written in the third person ("The user prefers ...").
|
|
348
|
+
|
|
349
|
+
Already known:
|
|
350
|
+
${known}
|
|
351
|
+
|
|
352
|
+
User: ${user}
|
|
353
|
+
|
|
354
|
+
${bot.name}: ${clip(replyText, 3000) || '(no reply)'}
|
|
355
|
+
|
|
356
|
+
Respond with ONLY a JSON object {"facts": ["..."]}. Use an empty list when nothing is worth keeping.`;
|
|
357
|
+
const r = await chatFn([{ role: 'user', content: prompt }]);
|
|
358
|
+
if (!r || !r.success || !r.json) return { added: [] };
|
|
359
|
+
const facts = (Array.isArray(r.json.facts) ? r.json.facts : [])
|
|
360
|
+
.filter((f) => typeof f === 'string')
|
|
361
|
+
.map((f) => f.trim())
|
|
362
|
+
.filter((f) => f.length >= 4 && !/(api[_ -]?key|password|secret|token)\s*[:=]/i.test(f))
|
|
363
|
+
.filter((f) => !(bot.memory || []).some((m) => factKey(m.fact) === factKey(f)))
|
|
364
|
+
.slice(0, MAX_FACTS_PER_TURN);
|
|
365
|
+
if (!facts.length) return { added: [] };
|
|
366
|
+
return addMemory(bot.id, facts);
|
|
367
|
+
} catch {
|
|
368
|
+
return { added: [] };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Experience: learning on the job ────────────────────────────────────────
|
|
373
|
+
// Memory (above) is about the USER. Experience is about the WORK, after MUSE
|
|
374
|
+
// ("Learning on the Job", arxiv 2510.08002): after a run that used tools, the
|
|
375
|
+
// bot reflects once on what it did (reflectOnRun) and keeps
|
|
376
|
+
// - lessons: short <situation, what works> pairs, always in its prompt
|
|
377
|
+
// - playbooks: named procedures for work that succeeded (app / task, when
|
|
378
|
+
// to use, steps with the real tool names, precautions); a
|
|
379
|
+
// one-line index is always in the prompt, the ones that fit
|
|
380
|
+
// the current request go in full
|
|
381
|
+
// - toolTips: how tools behaved for this bot and user
|
|
382
|
+
// - openThreads: what it left unfinished, until a later run finishes it
|
|
383
|
+
// All bounded, deduplicated, and stored on the bot file like memory.
|
|
384
|
+
|
|
385
|
+
const SECRET_RE = /(api[_ -]?key|password|passwd|secret|token|bearer)\s*[:=]|\bsk-[a-z0-9]{8,}/i;
|
|
386
|
+
const noSecret = (s) => !SECRET_RE.test(String(s || ''));
|
|
387
|
+
const cleanList = (v, max, n) => (Array.isArray(v) ? v : [])
|
|
388
|
+
.filter((s) => typeof s === 'string' && s.trim() && noSecret(s))
|
|
389
|
+
.map((s) => clip(s, n)).slice(0, max);
|
|
390
|
+
|
|
391
|
+
function cleanLessons(list) {
|
|
392
|
+
return (Array.isArray(list) ? list : [])
|
|
393
|
+
.filter((l) => l && typeof l.situation === 'string' && typeof l.strategy === 'string' && l.situation.trim() && l.strategy.trim())
|
|
394
|
+
.map((l) => ({ situation: clip(l.situation, 160), strategy: clip(l.strategy, 240), at: Number(l.at) || Date.now() }))
|
|
395
|
+
.slice(-MAX_LESSONS);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function cleanPlaybook(p) {
|
|
399
|
+
if (!p || typeof p !== 'object') return null;
|
|
400
|
+
const app = clip(p.app, 40);
|
|
401
|
+
const task = clip(p.task, 80);
|
|
402
|
+
const steps = cleanList(p.steps, 10, 200);
|
|
403
|
+
if (!app || !task || !steps.length) return null;
|
|
404
|
+
return {
|
|
405
|
+
app, task, when: clip(p.when, 200), steps,
|
|
406
|
+
precautions: cleanList(p.precautions, 6, 200),
|
|
407
|
+
params: noSecret(p.params) ? clip(p.params, 300) : '',
|
|
408
|
+
uses: Math.max(1, Number(p.uses) || 1),
|
|
409
|
+
at: Number(p.at) || Date.now(),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cleanPlaybooks(list) {
|
|
414
|
+
return (Array.isArray(list) ? list : []).map(cleanPlaybook).filter(Boolean).slice(-MAX_PLAYBOOKS);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function cleanTips(list) {
|
|
418
|
+
return (Array.isArray(list) ? list : [])
|
|
419
|
+
.map((t) => (typeof t === 'string' ? { tip: t } : t))
|
|
420
|
+
.filter((t) => t && typeof t.tip === 'string' && t.tip.trim())
|
|
421
|
+
.map((t) => ({ tip: clip(t.tip, 200), at: Number(t.at) || Date.now() }))
|
|
422
|
+
.slice(-MAX_TOOL_TIPS);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function cleanThreads(list) {
|
|
426
|
+
return (Array.isArray(list) ? list : [])
|
|
427
|
+
.map((t) => (typeof t === 'string' ? { text: t } : t))
|
|
428
|
+
.filter((t) => t && typeof t.text === 'string' && t.text.trim())
|
|
429
|
+
.map((t) => ({ text: clip(t.text, 200), at: Number(t.at) || Date.now() }))
|
|
430
|
+
.slice(-MAX_OPEN_THREADS);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const playbookKey = (p) => `${factKey(p.app)}|${factKey(p.task)}`;
|
|
434
|
+
const playbookName = (p) => `${p.app} / ${p.task}`;
|
|
435
|
+
const uniqueStrings = (list, max) => {
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const s of list) if (!out.some((x) => sameFact(x, s))) out.push(s);
|
|
438
|
+
return out.slice(-max);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Pure merge of one reflection into a bot's experience. Same lesson
|
|
443
|
+
* situation, same tool tip, same app + task: the newer one replaces (a
|
|
444
|
+
* playbook is updated in place, keeping its use count and precautions).
|
|
445
|
+
* @returns {{experience:object, added:object}}
|
|
446
|
+
*/
|
|
447
|
+
function mergeExperience(exp, r, now = Date.now()) {
|
|
448
|
+
let lessons = cleanLessons(exp && exp.lessons);
|
|
449
|
+
let playbooks = cleanPlaybooks(exp && exp.playbooks);
|
|
450
|
+
let toolTips = cleanTips(exp && exp.toolTips);
|
|
451
|
+
let openThreads = cleanThreads(exp && exp.openThreads);
|
|
452
|
+
const added = { lessons: [], toolTips: [], playbook: null, updated: false, unfinished: '', resolved: [] };
|
|
453
|
+
for (const l of cleanLessons((r && r.lessons) || [])) {
|
|
454
|
+
if (!noSecret(l.situation + l.strategy)) continue;
|
|
455
|
+
lessons = lessons.filter((x) => !sameFact(x.situation, l.situation) && !sameFact(x.strategy, l.strategy));
|
|
456
|
+
lessons.push({ ...l, at: now });
|
|
457
|
+
added.lessons.push(l);
|
|
458
|
+
}
|
|
459
|
+
for (const t of cleanTips(((r && r.toolTips) || []).filter(noSecret))) {
|
|
460
|
+
toolTips = toolTips.filter((x) => !sameFact(x.tip, t.tip));
|
|
461
|
+
toolTips.push({ ...t, at: now });
|
|
462
|
+
added.toolTips.push(t.tip);
|
|
463
|
+
}
|
|
464
|
+
const pb = r && r.playbook ? cleanPlaybook(r.playbook) : null;
|
|
465
|
+
if (pb) {
|
|
466
|
+
const old = playbooks.find((x) => playbookKey(x) === playbookKey(pb));
|
|
467
|
+
const merged = old
|
|
468
|
+
? { ...pb, when: pb.when || old.when, params: pb.params || old.params, precautions: uniqueStrings([...old.precautions, ...pb.precautions], 6), uses: old.uses + 1, at: now }
|
|
469
|
+
: { ...pb, uses: 1, at: now };
|
|
470
|
+
playbooks = playbooks.filter((x) => x !== old);
|
|
471
|
+
playbooks.push(merged); // most recently used last, so the oldest unused one goes first
|
|
472
|
+
added.playbook = playbookName(merged);
|
|
473
|
+
added.updated = !!old;
|
|
474
|
+
}
|
|
475
|
+
// Open threads: resolved ones go (by number or by text), a new one is added.
|
|
476
|
+
const resolved = Array.isArray(r && r.resolved) ? r.resolved : [];
|
|
477
|
+
if (resolved.length) {
|
|
478
|
+
const before = openThreads;
|
|
479
|
+
openThreads = openThreads.filter((t, i) => !resolved.some((x) => (typeof x === 'number' ? x === i + 1 : (/^\d+$/.test(String(x).trim()) ? Number(x) === i + 1 : sameFact(t.text, x)))));
|
|
480
|
+
added.resolved = before.filter((t) => !openThreads.includes(t)).map((t) => t.text);
|
|
481
|
+
}
|
|
482
|
+
const unfinished = r && typeof r.unfinished === 'string' && noSecret(r.unfinished) ? clip(r.unfinished, 200) : '';
|
|
483
|
+
if (unfinished && !/^(none|nothing|n\/a|no)\.?$/i.test(unfinished)) {
|
|
484
|
+
openThreads = openThreads.filter((t) => !sameFact(t.text, unfinished));
|
|
485
|
+
openThreads.push({ text: unfinished, at: now });
|
|
486
|
+
added.unfinished = unfinished;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
experience: {
|
|
490
|
+
lessons: lessons.slice(-MAX_LESSONS), playbooks: playbooks.slice(-MAX_PLAYBOOKS),
|
|
491
|
+
toolTips: toolTips.slice(-MAX_TOOL_TIPS), openThreads: openThreads.slice(-MAX_OPEN_THREADS),
|
|
492
|
+
},
|
|
493
|
+
added,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Merge a reflection into the stored bot (re-read first, so a parallel learn is never lost). */
|
|
498
|
+
function addExperience(botId, reflection) {
|
|
499
|
+
const bot = getBot(botId);
|
|
500
|
+
if (!bot) return { bot: null, added: null };
|
|
501
|
+
const { experience, added } = mergeExperience(bot, reflection);
|
|
502
|
+
return { bot: save({ ...bot, ...experience, updatedAt: Date.now() }), added };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const EXPERIENCE_KINDS = ['lessons', 'playbooks', 'toolTips', 'openThreads'];
|
|
506
|
+
|
|
507
|
+
/** Forget one experience item (kind + index), or everything with kind 'all'. */
|
|
508
|
+
function forgetExperience(botId, kind, index) {
|
|
509
|
+
const bot = getBot(botId);
|
|
510
|
+
if (!bot) throw new Error('That bot is gone.');
|
|
511
|
+
if (kind === 'all') return save({ ...bot, lessons: [], playbooks: [], toolTips: [], openThreads: [], updatedAt: Date.now() });
|
|
512
|
+
if (!EXPERIENCE_KINDS.includes(kind)) throw new Error('Unknown kind of experience.');
|
|
513
|
+
const list = bot[kind].slice();
|
|
514
|
+
if (index >= 0 && index < list.length) list.splice(index, 1);
|
|
515
|
+
return save({ ...bot, [kind]: list, updatedAt: Date.now() });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const STOP = new Set('the and for with that this from what when where which your you our are was were can could would should will have has had not but all any how who why into onto about please just some them they then than there these those its it him her his she use using want need make get got give show tell check look find out new one two'.split(' '));
|
|
519
|
+
|
|
520
|
+
/** Words that carry meaning, lightly stemmed ("emails" and "email" match). */
|
|
521
|
+
function terms(text) {
|
|
522
|
+
return [...new Set(String(text || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/)
|
|
523
|
+
.filter((w) => w.length >= 3 && !STOP.has(w))
|
|
524
|
+
.map((w) => (w.length > 4 ? w.replace(/(ing|es|s)$/, '') : w)))];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The playbooks that fit a request best (keyword overlap), at most `n`, best first. */
|
|
528
|
+
function rankPlaybooks(playbooks, request, n = PLAYBOOKS_IN_FULL) {
|
|
529
|
+
const want = terms(request);
|
|
530
|
+
if (!want.length) return [];
|
|
531
|
+
return (playbooks || []).map((p, i) => {
|
|
532
|
+
const head = terms(`${p.app} ${p.task}`);
|
|
533
|
+
const body = terms(`${p.when} ${(p.steps || []).join(' ')}`);
|
|
534
|
+
let score = 0;
|
|
535
|
+
for (const w of want) score += head.includes(w) ? 2 : body.includes(w) ? 0.5 : 0;
|
|
536
|
+
return { p, i, score };
|
|
537
|
+
}).filter((x) => x.score >= 2 || (x.score >= 1 && want.length <= 2))
|
|
538
|
+
.sort((a, b) => b.score - a.score || b.p.at - a.p.at)
|
|
539
|
+
.slice(0, n).map((x) => x.p);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** One line per tool call: name, short args, ok or the error. */
|
|
543
|
+
function trajectoryLines(steps) {
|
|
544
|
+
return (Array.isArray(steps) ? steps : []).filter((s) => s && s.name).slice(-30).map((s, i) => {
|
|
545
|
+
let args = '';
|
|
546
|
+
if (s.args && typeof s.args === 'object') {
|
|
547
|
+
const short = {};
|
|
548
|
+
for (const [k, v] of Object.entries(s.args)) {
|
|
549
|
+
if (/content|patch|body|text|old|new/i.test(k) && typeof v === 'string' && v.length > 60) short[k] = `(${v.length} chars)`;
|
|
550
|
+
else short[k] = typeof v === 'string' ? clip(v, 80) : v;
|
|
551
|
+
}
|
|
552
|
+
args = clip(JSON.stringify(short), 200);
|
|
553
|
+
} else if (s.label) args = clip(s.label, 140);
|
|
554
|
+
const result = s.ok ? 'ok' : `error${s.error ? `: ${clip(s.error, 140)}` : ''}`;
|
|
555
|
+
return `${i + 1}. ${s.name} ${args} -> ${result}`;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function reflectPrompt(bot, { request, steps, reply }) {
|
|
560
|
+
const index = (bot.playbooks || []).map((p) => `- ${playbookName(p)}`).join('\n') || '(none yet)';
|
|
561
|
+
const lessons = (bot.lessons || []).map((l) => `- When ${l.situation}: ${l.strategy}`).join('\n') || '(none yet)';
|
|
562
|
+
const threads = (bot.openThreads || []).map((t, i) => `${i + 1}. ${t.text}`).join('\n') || '(none)';
|
|
563
|
+
return `You are the reflection step of an assistant called ${bot.name}${bot.specialty ? ` (${bot.specialty})` : ''}. It just finished a piece of work with its tools. Look at what it did and decide what it should learn for next time, so it gets better on the job.
|
|
564
|
+
|
|
565
|
+
The user's request:
|
|
566
|
+
${clip(request, 2000) || '(not given)'}
|
|
567
|
+
|
|
568
|
+
The tool calls, in order:
|
|
569
|
+
${trajectoryLines(steps).join('\n') || '(none)'}
|
|
570
|
+
|
|
571
|
+
${bot.name}'s final reply:
|
|
572
|
+
${clip(reply, 2000) || '(no reply)'}
|
|
573
|
+
|
|
574
|
+
Playbooks it already has (reuse the exact same app and task names to update one):
|
|
575
|
+
${index}
|
|
576
|
+
|
|
577
|
+
Lessons it already has:
|
|
578
|
+
${lessons}
|
|
579
|
+
|
|
580
|
+
Open threads it left unfinished before (numbered):
|
|
581
|
+
${threads}
|
|
582
|
+
|
|
583
|
+
Judge the outcome from the tool results, not from what the reply claims. Then respond with ONLY a JSON object:
|
|
584
|
+
{
|
|
585
|
+
"outcome": "success" or "partial" or "failed",
|
|
586
|
+
"playbook": null, or ONLY when it succeeded and the procedure is reusable: {"app": "the app or area, e.g. Gmail, Files, Web, Shell", "task": "short task name, e.g. find important emails", "when": "one line: when to use it", "steps": ["each key step, naming the real tool used, e.g. gmail_search with q=is:important newer_than:2d"], "precautions": ["what to watch out for"], "params": "parameters or values that worked, or empty"},
|
|
587
|
+
"lessons": [{"situation": "a short kind of situation", "strategy": "what works there, or what to avoid"}] (at most 2, only new and general ones),
|
|
588
|
+
"toolTips": ["tool_name: how it behaved, e.g. a query or argument that worked or failed"] (at most 2),
|
|
589
|
+
"unfinished": "one line about anything the user asked for that is still not done, or empty",
|
|
590
|
+
"resolved": [numbers of the open threads above that this work finished]
|
|
591
|
+
}
|
|
592
|
+
Never include passwords, keys, tokens or personal secrets. Keep every line short. Never use the long dash character.`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* MUSE "Reflect + Memorize": after a run that used tools, one cheap model
|
|
597
|
+
* call turns the trajectory into experience on the bot. chatFn has the
|
|
598
|
+
* ai.js chatJson shape. Never throws.
|
|
599
|
+
* @param {object} bot
|
|
600
|
+
* @param {{request:string, steps:Array<{name:string,args?:object,label?:string,ok:boolean,error?:string}>, reply:string}} run
|
|
601
|
+
* @returns {Promise<{outcome?:string, added:object|null, skipped?:string}>}
|
|
602
|
+
*/
|
|
603
|
+
async function reflectOnRun(bot, run, chatFn) {
|
|
604
|
+
try {
|
|
605
|
+
if (!bot || !bot.id || typeof chatFn !== 'function') return { added: null, skipped: 'no bot or model' };
|
|
606
|
+
const steps = (run && Array.isArray(run.steps) ? run.steps : []).filter((s) => s && s.name);
|
|
607
|
+
if (!steps.length) return { added: null, skipped: 'no tools used' };
|
|
608
|
+
const fresh = getBot(bot.id) || normalizeBot(bot, bot);
|
|
609
|
+
const r = await chatFn([{ role: 'user', content: reflectPrompt(fresh, { ...run, steps }) }]);
|
|
610
|
+
if (!r || !r.success || !r.json || typeof r.json !== 'object') return { added: null, skipped: 'no answer' };
|
|
611
|
+
const j = r.json;
|
|
612
|
+
const outcome = ['success', 'partial', 'failed'].includes(j.outcome) ? j.outcome : 'partial';
|
|
613
|
+
const reflection = {
|
|
614
|
+
playbook: outcome === 'success' ? j.playbook : null,
|
|
615
|
+
lessons: (Array.isArray(j.lessons) ? j.lessons : []).slice(0, 2),
|
|
616
|
+
toolTips: (Array.isArray(j.toolTips) ? j.toolTips : []).slice(0, 2),
|
|
617
|
+
unfinished: j.unfinished,
|
|
618
|
+
resolved: j.resolved,
|
|
619
|
+
};
|
|
620
|
+
const { added } = addExperience(bot.id, reflection);
|
|
621
|
+
return { outcome, added };
|
|
622
|
+
} catch {
|
|
623
|
+
return { added: null, skipped: 'failed' };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ─── Prompts ────────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
function approvalLine(bot) {
|
|
630
|
+
const must = (bot.approval || []).map((k) => APPROVALS[k] && APPROVALS[k].label.toLowerCase()).filter(Boolean);
|
|
631
|
+
if (!must.length) return 'You may act without asking first. Still never do anything irreversible the user did not ask for.';
|
|
632
|
+
return `Always get the user's approval before: ${must.join('; ')}. Craft enforces this too. Do reversible, read-only work (reading, searching, thinking) without asking.`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function rosterLines(team, selfId) {
|
|
636
|
+
return (team || []).filter((b) => b.id !== selfId)
|
|
637
|
+
.map((b) => `- ${b.name} (${b.role === 'orchestrator' ? 'orchestrator' : 'specialist'}): ${b.specialty || 'general help'}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const ASK_BOT_FORMAT = `To ask a teammate, write:
|
|
641
|
+
<codeply:ask_bot>
|
|
642
|
+
<bot>Name</bot>
|
|
643
|
+
<task>
|
|
644
|
+
The one task, with every bit of context they need (they see nothing else).
|
|
645
|
+
</task>
|
|
646
|
+
</codeply:ask_bot>`;
|
|
647
|
+
|
|
648
|
+
const VERIFY_RULES = `BEFORE YOU SAY IT IS DONE
|
|
649
|
+
- Base every claim on what your tools actually returned in this run.
|
|
650
|
+
- Check the deliverable exists (the file is written, the message is sent, the command passed) before you say so.
|
|
651
|
+
- Never invent results, numbers, names or links. If something failed or you could not check it, say so plainly.`;
|
|
652
|
+
|
|
653
|
+
const shortDate = (at) => new Date(Number(at) || Date.now()).toISOString().slice(0, 10);
|
|
654
|
+
|
|
655
|
+
function playbookText(p) {
|
|
656
|
+
const lines = [`${playbookName(p)}`];
|
|
657
|
+
if (p.when) lines.push(`When to use: ${p.when}`);
|
|
658
|
+
lines.push('Steps:', ...(p.steps || []).map((s, i) => `${i + 1}. ${s}`));
|
|
659
|
+
if ((p.precautions || []).length) lines.push('Precautions:', ...p.precautions.map((s) => `- ${s}`));
|
|
660
|
+
if (p.params) lines.push(`What worked: ${p.params}`);
|
|
661
|
+
return lines.join('\n');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The experience sections of a bot's prompt; `request` picks the playbooks shown in full. */
|
|
665
|
+
function experienceParts(bot, request) {
|
|
666
|
+
const parts = [];
|
|
667
|
+
const lessons = bot.lessons || [];
|
|
668
|
+
if (lessons.length) {
|
|
669
|
+
parts.push(`WHAT YOU HAVE LEARNED FROM PAST WORK\nStrategies from your own earlier work. Use them when the situation fits:\n${lessons.map((l) => `- When ${l.situation}: ${l.strategy}`).join('\n')}`);
|
|
670
|
+
}
|
|
671
|
+
const tips = bot.toolTips || [];
|
|
672
|
+
if (tips.length) parts.push(`TOOL TIPS\nHow your tools behaved before:\n${tips.map((t) => `- ${t.tip}`).join('\n')}`);
|
|
673
|
+
const playbooks = bot.playbooks || [];
|
|
674
|
+
if (playbooks.length) {
|
|
675
|
+
const top = rankPlaybooks(playbooks, request);
|
|
676
|
+
const index = playbooks.slice().reverse().map((p) => `- ${playbookName(p)}${p.when ? `: ${clip(p.when, 90)}` : ''}${top.includes(p) ? ' (in full below)' : ''}`);
|
|
677
|
+
parts.push(`YOUR PLAYBOOKS\nProcedures that worked for you before (app / task). Follow the matching one, and adapt it if things changed:\n${index.join('\n')}`);
|
|
678
|
+
if (top.length) parts.push(`PLAYBOOKS FOR THIS REQUEST\n${top.map(playbookText).join('\n\n')}`);
|
|
679
|
+
}
|
|
680
|
+
const threads = bot.openThreads || [];
|
|
681
|
+
if (threads.length) {
|
|
682
|
+
parts.push(`OPEN THREADS\nThings you left unfinished before. Bring one up when it fits, and finish it when the user wants:\n${threads.map((t) => `- (${shortDate(t.at)}) ${t.text}`).join('\n')}`);
|
|
683
|
+
}
|
|
684
|
+
return parts;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The system prompt addition for one bot.
|
|
689
|
+
* @param {object} bot
|
|
690
|
+
* @param {object} [o]
|
|
691
|
+
* @param {object[]} [o.team] every bot (the roster leaves this one out)
|
|
692
|
+
* @param {boolean} [o.canDelegate] ask_bot is available in this run
|
|
693
|
+
* @param {string} [o.askedBy] set for a delegated sub-run: who asked
|
|
694
|
+
* @param {boolean} [o.native] native function calling (no tag example)
|
|
695
|
+
* @param {string} [o.request] the user's message: picks the playbooks shown in full
|
|
696
|
+
*/
|
|
697
|
+
function buildBotPrompt(bot, o = {}) {
|
|
698
|
+
if (!bot) return '';
|
|
699
|
+
const parts = [];
|
|
700
|
+
const role = bot.role === 'orchestrator' ? 'the orchestrator of a small team of bots' : 'a specialist bot';
|
|
701
|
+
parts.push(`BOT IDENTITY\nYou are ${bot.name}, ${role} inside Codeply Craft. Speak as ${bot.name}. You are still the same careful agent with the same tools and rules; this is who you are for this chat.`);
|
|
702
|
+
if (bot.specialty) parts.push(`SPECIALTY\n${bot.specialty}. Stay in this lane; if a request is clearly outside it, say so briefly and do the best you can.`);
|
|
703
|
+
if (bot.instructions) parts.push(`HOW YOU WORK\n${bot.instructions}`);
|
|
704
|
+
const tone = TONES[bot.tone && bot.tone.preset] || TONES.friendly;
|
|
705
|
+
parts.push(`TONE\n${tone.text}${bot.tone && bot.tone.custom ? ` ${bot.tone.custom}` : ''}`);
|
|
706
|
+
if (bot.sources) parts.push(`SOURCES AND NOTES\n${bot.sources}`);
|
|
707
|
+
parts.push(`APPROVAL BOUNDARY\n${approvalLine(bot)}`);
|
|
708
|
+
const memory = (bot.memory || []).slice(-MAX_MEMORY);
|
|
709
|
+
if (memory.length) {
|
|
710
|
+
parts.push(`WHAT YOU HAVE LEARNED ABOUT THIS USER\nFollow these unless the user says otherwise now:\n${memory.map((m) => `- ${m.fact}`).join('\n')}`);
|
|
711
|
+
}
|
|
712
|
+
parts.push(...experienceParts(bot, o.request));
|
|
713
|
+
parts.push(VERIFY_RULES);
|
|
714
|
+
if (o.askedBy) {
|
|
715
|
+
parts.push(`DELEGATED TASK\n${o.askedBy} asked you to do one task. You only see that task, not the rest of the conversation. Do it fully with your tools, then reply in this shape:\nSummary: <one sentence with the outcome>\n<the details: findings, what you changed, anything ${o.askedBy} must know or check>`);
|
|
716
|
+
}
|
|
717
|
+
const roster = rosterLines(o.team, bot.id);
|
|
718
|
+
if (o.canDelegate && roster.length) {
|
|
719
|
+
const lead = bot.role === 'orchestrator'
|
|
720
|
+
? `You lead this team. For a goal with several parts: decompose it into small steps, give each step to the teammate whose domain fits with ask_bot, ONE AT A TIME (only one bot ever runs at once), read and check each result before the next step (ask again with a correction if it falls short), then merge everything into one clear answer. Do small or simple things yourself.`
|
|
721
|
+
: `You can ask a teammate for help with ask_bot when part of the work is clearly in their domain. Only one bot runs at a time, and you wait for the answer.`;
|
|
722
|
+
parts.push(`TEAM\n${lead}\nTeammates:\n${roster.join('\n')}${o.native ? '' : `\n\n${ASK_BOT_FORMAT}`}`);
|
|
723
|
+
}
|
|
724
|
+
return parts.join('\n\n');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The short roster note when the plain Craft agent (no bot chosen) can still ask bots. */
|
|
728
|
+
function teamPrompt(team, o = {}) {
|
|
729
|
+
const roster = rosterLines(team, null);
|
|
730
|
+
if (!roster.length) return '';
|
|
731
|
+
return `BOTS\nThe user has these bots. When they ask for one by name, or a part of the work is clearly a bot's specialty, you can hand it that part with ask_bot. Only one bot runs at a time, and you wait for its answer.\n${roster.join('\n')}${o.native ? '' : `\n\n${ASK_BOT_FORMAT}`}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Which approval category a tool falls into, or null. */
|
|
735
|
+
function approvalCategory(tool) {
|
|
736
|
+
for (const [k, v] of Object.entries(APPROVALS)) if (v.tools.includes(tool)) return k;
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** True when this bot is allowed to do this tool without asking the user. */
|
|
741
|
+
function canSkipApproval(bot, tool) {
|
|
742
|
+
if (!bot) return false;
|
|
743
|
+
const cat = approvalCategory(tool);
|
|
744
|
+
return !!cat && !(bot.approval || []).includes(cat);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ─── One agent at a time ────────────────────────────────────────────────────
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* A lock with an owner token. The run that holds it may take it again (a bot
|
|
751
|
+
* asking a teammate while it waits), anyone else queues. So exactly one agent
|
|
752
|
+
* chain runs at a time, and a nested ask never deadlocks.
|
|
753
|
+
*/
|
|
754
|
+
function createLock() {
|
|
755
|
+
let owner = null;
|
|
756
|
+
let holds = 0;
|
|
757
|
+
const queue = [];
|
|
758
|
+
function acquire(token, signal) {
|
|
759
|
+
if (owner === token) { holds++; return Promise.resolve(); }
|
|
760
|
+
if (owner === null) { owner = token; holds = 1; return Promise.resolve(); }
|
|
761
|
+
return new Promise((resolve, reject) => {
|
|
762
|
+
const w = { token, resolve };
|
|
763
|
+
queue.push(w);
|
|
764
|
+
if (signal) {
|
|
765
|
+
if (signal.aborted) { queue.splice(queue.indexOf(w), 1); reject(new Error('Stopped.')); return; }
|
|
766
|
+
signal.addEventListener('abort', () => {
|
|
767
|
+
const i = queue.indexOf(w);
|
|
768
|
+
if (i >= 0) { queue.splice(i, 1); reject(new Error('Stopped.')); }
|
|
769
|
+
}, { once: true });
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function release(token) {
|
|
774
|
+
if (owner !== token) return;
|
|
775
|
+
if (--holds > 0) return;
|
|
776
|
+
owner = null;
|
|
777
|
+
const next = queue.shift();
|
|
778
|
+
if (next) { owner = next.token; holds = 1; next.resolve(); }
|
|
779
|
+
}
|
|
780
|
+
async function run(token, fn, signal) {
|
|
781
|
+
await acquire(token, signal);
|
|
782
|
+
try { return await fn(); } finally { release(token); }
|
|
783
|
+
}
|
|
784
|
+
return { acquire, release, run, get owner() { return owner; }, get waiting() { return queue.length; } };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const globalLock = createLock();
|
|
788
|
+
|
|
789
|
+
/** "Summary: ..." first line, else the first sentence. */
|
|
790
|
+
function parseResult(text) {
|
|
791
|
+
const t = String(text || '').trim();
|
|
792
|
+
if (!t) return { summary: 'No answer.', details: '' };
|
|
793
|
+
const m = t.match(/^\s*\**summary\**\s*[:\-]\s*(.+)$/im);
|
|
794
|
+
if (m) {
|
|
795
|
+
const details = t.replace(m[0], '').trim();
|
|
796
|
+
return { summary: clip(m[1].replace(/\*+$/, ''), 300), details };
|
|
797
|
+
}
|
|
798
|
+
const first = t.split(/\n/)[0];
|
|
799
|
+
const sentence = (first.match(/^.{10,300}?[.!?](\s|$)/) || [first])[0].trim();
|
|
800
|
+
return { summary: clip(sentence, 300), details: t };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The ask_bot tool, minus the actual model run (the host passes runBot).
|
|
805
|
+
*
|
|
806
|
+
* @param {object} o
|
|
807
|
+
* @param {object|null} o.caller the bot asking (null = plain Craft)
|
|
808
|
+
* @param {string} o.name who to ask
|
|
809
|
+
* @param {string} o.task the task, with its context
|
|
810
|
+
* @param {number} o.depth depth of the CALLER (top-level run = 0)
|
|
811
|
+
* @param {string[]} [o.chain] bot ids already in this delegation chain
|
|
812
|
+
* @param {string} o.token lock owner token for this chain
|
|
813
|
+
* @param {AbortSignal} [o.signal]
|
|
814
|
+
* @param {object[]} [o.team] every bot (defaults to the store)
|
|
815
|
+
* @param {object} [o.lock] defaults to the global lock
|
|
816
|
+
* @param {Function} o.runBot async (bot, {prompt, task, depth, chain}) => reply text
|
|
817
|
+
* @returns {Promise<{ok:boolean, output:string, meta:object}>}
|
|
818
|
+
*/
|
|
819
|
+
async function delegate(o) {
|
|
820
|
+
const team = o.team || listBots();
|
|
821
|
+
const task = clip(o.task, 8000);
|
|
822
|
+
const target = findBot(o.name, team);
|
|
823
|
+
const callerName = o.caller ? o.caller.name : 'Craft';
|
|
824
|
+
const fail = (output) => ({ ok: false, output, meta: { label: `ask ${o.name || '?'}: refused`, delegation: { bot: target ? botCard(target) : { name: String(o.name || '?') }, task, ok: false, error: output } } });
|
|
825
|
+
if (!target) return fail(`There is no bot called "${o.name}". Teammates: ${team.map((b) => b.name).join(', ') || 'none'}.`);
|
|
826
|
+
if (!task) return fail('ask_bot needs a task: say exactly what you want done, with the context.');
|
|
827
|
+
if (o.caller && target.id === o.caller.id) return fail(`You are ${target.name}; do it yourself instead of asking yourself.`);
|
|
828
|
+
const chain = Array.isArray(o.chain) ? o.chain : [];
|
|
829
|
+
if (chain.includes(target.id)) return fail(`${target.name} is already working on this chain of requests; asking it again would loop. Do it yourself or ask someone else.`);
|
|
830
|
+
const depth = Number(o.depth) || 0;
|
|
831
|
+
if (depth >= MAX_DEPTH) return fail(`Delegation is limited to ${MAX_DEPTH} levels. Do this part yourself.`);
|
|
832
|
+
const lock = o.lock || globalLock;
|
|
833
|
+
const started = Date.now();
|
|
834
|
+
let reply;
|
|
835
|
+
try {
|
|
836
|
+
reply = await lock.run(o.token, () => o.runBot(target, {
|
|
837
|
+
task, depth: depth + 1, chain: [...chain, ...(o.caller ? [o.caller.id] : []), target.id],
|
|
838
|
+
prompt: buildBotPrompt(target, { team, askedBy: callerName, canDelegate: depth + 1 < MAX_DEPTH, request: task }),
|
|
839
|
+
}), o.signal);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
const output = `${target.name} could not finish: ${e.message}`;
|
|
842
|
+
return { ok: false, output, meta: { label: `${target.name}: failed`, delegation: { bot: botCard(target), task, ok: false, error: e.message, ms: Date.now() - started } } };
|
|
843
|
+
}
|
|
844
|
+
const { summary, details } = parseResult(reply);
|
|
845
|
+
return {
|
|
846
|
+
ok: true,
|
|
847
|
+
output: `RESULT FROM ${target.name.toUpperCase()}\nSummary: ${summary}\n\n${details || '(no details)'}\n\nCheck this against the goal before you build on it.`,
|
|
848
|
+
meta: { label: `${target.name}: ${summary}`.slice(0, 160), delegation: { bot: botCard(target), task, ok: true, summary, details: clip(details, 6000), ms: Date.now() - started } },
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** What a chat row or a chip needs to draw a bot. */
|
|
853
|
+
function botCard(b) {
|
|
854
|
+
return b ? { id: b.id, name: b.name, role: b.role, avatar: b.avatar, specialty: b.specialty } : null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─── "Describe your bot" ────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
function describePrompt(description) {
|
|
860
|
+
return `Design a helpful AI bot from this description. The bot lives inside Codeply Craft, a desktop coding agent app, and has the same tools (read and edit project files, run commands, search the web).
|
|
861
|
+
|
|
862
|
+
Description: ${clip(description, 2000)}
|
|
863
|
+
|
|
864
|
+
Respond with ONLY a JSON object:
|
|
865
|
+
{
|
|
866
|
+
"name": "a short, friendly first name (one word)",
|
|
867
|
+
"role": "specialist" or "orchestrator" (orchestrator only if it should coordinate other bots),
|
|
868
|
+
"specialty": "one line: its single clear domain",
|
|
869
|
+
"instructions": "2 to 4 sentences: how it works, what it always does, what it never does",
|
|
870
|
+
"tone": {"preset": one of ${Object.keys(TONES).map((k) => `"${k}"`).join(', ')}, "custom": "optional extra tone note"},
|
|
871
|
+
"sources": "where it should look first, or empty",
|
|
872
|
+
"approval": a list from ${ALL_APPROVALS.map((k) => `"${k}"`).join(', ')}: what it must ask before doing (keep everything irreversible),
|
|
873
|
+
"avatar": {"shape": one of ${AVATAR_KEYS.shape.map((k) => `"${k}"`).join(', ')}, "eyes": one of ${AVATAR_KEYS.eyes.map((k) => `"${k}"`).join(', ')}, "color": one of ${AVATAR_KEYS.color.map((k) => `"${k}"`).join(', ')}, "glasses": one of ${AVATAR_KEYS.glasses.map((k) => `"${k}"`).join(', ')}, "accessory": one of ${AVATAR_KEYS.accessory.map((k) => `"${k}"`).join(', ')}, "mouth": one of ${AVATAR_KEYS.mouth.map((k) => `"${k}"`).join(', ')}}
|
|
874
|
+
}
|
|
875
|
+
Pick an avatar that fits its personality. Never use the long dash character.`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Turn what the model returned into bot fields (not saved). */
|
|
879
|
+
function fromDescription(json, description) {
|
|
880
|
+
const j = json && typeof json === 'object' ? json : {};
|
|
881
|
+
const draft = normalizeBot({
|
|
882
|
+
name: j.name, role: j.role, specialty: j.specialty || clip(description, 120), instructions: j.instructions,
|
|
883
|
+
tone: j.tone, sources: j.sources, approval: Array.isArray(j.approval) ? j.approval : undefined, avatar: j.avatar,
|
|
884
|
+
});
|
|
885
|
+
const dash = String.fromCharCode(0x2014); // the long dash, written without typing it
|
|
886
|
+
const noDash = (s) => String(s || '').split(` ${dash} `).join(', ').split(dash).join('-');
|
|
887
|
+
for (const k of ['name', 'specialty', 'instructions', 'sources']) draft[k] = noDash(draft[k]);
|
|
888
|
+
draft.tone.custom = noDash(draft.tone.custom);
|
|
889
|
+
// Irreversible actions always stay behind approval, whatever the model said.
|
|
890
|
+
for (const k of ['send', 'publish', 'databases']) if (!draft.approval.includes(k)) draft.approval.push(k);
|
|
891
|
+
delete draft.id; delete draft.memory; delete draft.createdAt; delete draft.updatedAt;
|
|
892
|
+
delete draft.lessons; delete draft.playbooks; delete draft.toolTips; delete draft.openThreads;
|
|
893
|
+
return draft;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ─── Voice calls ────────────────────────────────────────────────────────────
|
|
897
|
+
// A call turn is a real agent run: the bot keeps its tools (Gmail, files, the
|
|
898
|
+
// web, commands) and its approval boundary, and only the way it answers
|
|
899
|
+
// changes, so "check my email" on a call really checks it.
|
|
900
|
+
|
|
901
|
+
const VOICE_RULES = `LIVE VOICE CALL
|
|
902
|
+
You are on a live voice call with the user, talking out loud. Your final reply is spoken by a voice engine.
|
|
903
|
+
- You still have all your tools. When the user asks for real work (check their email, look something up, read or change a file), do it now with your tools, then tell them the result.
|
|
904
|
+
- Speak the result in one to three short sentences, the way people talk. Summarize: for emails say who it is from and what it is about, never read out long text, links or ids.
|
|
905
|
+
- No markdown, no lists, no headings, no emojis, no code, no links, no long dash in what you say.
|
|
906
|
+
- Ask one short question back when it helps.
|
|
907
|
+
- Anything that needs the user's OK (sending, changing files, running commands) is asked on their screen; if they say no, tell them plainly.`;
|
|
908
|
+
|
|
909
|
+
const VOICE_RULES_NO_TOOLS = `LIVE VOICE CALL
|
|
910
|
+
You are on a live voice call with the user, talking out loud. Everything you write is spoken by a voice engine.
|
|
911
|
+
- Reply in one to three short spoken sentences. Plain words, the way people talk.
|
|
912
|
+
- No markdown, no lists, no headings, no emojis, no code, no links, no long dash.
|
|
913
|
+
- Ask one short question back when it helps.
|
|
914
|
+
- Right now you cannot use your tools (email, files, the web), because the user's PC is offline. If they ask for that kind of work, say so in one sentence and offer to do it once their PC is on.`;
|
|
915
|
+
|
|
916
|
+
/** Whatever the model sent, make it sayable: no markdown, no long dashes. */
|
|
917
|
+
function spoken(s) {
|
|
918
|
+
const dash = String.fromCharCode(0x2014);
|
|
919
|
+
return String(s || '')
|
|
920
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
921
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
922
|
+
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
|
923
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
924
|
+
.replace(/[*_#`>]+/g, '')
|
|
925
|
+
.split(dash).join(', ')
|
|
926
|
+
.replace(/\s+/g, ' ')
|
|
927
|
+
.trim();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The call so far as chat turns: the last thing the user said is the new message. */
|
|
931
|
+
function callTurns(turns) {
|
|
932
|
+
const list = (Array.isArray(turns) ? turns : []).filter((t) => t && t.text).slice(-16)
|
|
933
|
+
.map((t) => ({ role: t.who === 'bot' ? 'assistant' : 'user', content: String(t.text).slice(0, 1200) }));
|
|
934
|
+
let lastUser = -1;
|
|
935
|
+
list.forEach((t, i) => { if (t.role === 'user') lastUser = i; });
|
|
936
|
+
if (lastUser < 0) return { history: [], userMessage: '' };
|
|
937
|
+
const history = [];
|
|
938
|
+
for (const t of list.slice(0, lastUser)) {
|
|
939
|
+
const prev = history[history.length - 1];
|
|
940
|
+
if (prev && prev.role === t.role) prev.content += `\n${t.content}`; else history.push({ ...t });
|
|
941
|
+
}
|
|
942
|
+
while (history.length && history[0].role !== 'user') history.shift();
|
|
943
|
+
return { history, userMessage: list.slice(lastUser).filter((t) => t.role === 'user').map((t) => t.content).join('\n') };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* One spoken reply on a call, with tools. `runAgent` is the engine's agent
|
|
948
|
+
* loop; `onStep({ name, label, done, ok })` reports tool use for the call
|
|
949
|
+
* screen ("Checking Gmail..."). Returns { text } ready to speak, plus
|
|
950
|
+
* { request, reply, steps } for reflectOnRun.
|
|
951
|
+
*/
|
|
952
|
+
async function voiceTurn({ bot, team, turns, recentChat, runAgent, route, cwd, signal, approve, onStep, maxSteps = 16 }) {
|
|
953
|
+
const { history, userMessage } = callTurns(turns);
|
|
954
|
+
if (!userMessage) return { text: '' };
|
|
955
|
+
const extra = recentChat ? `\n\nRECENT CHAT BEFORE THIS CALL\n${recentChat}` : '';
|
|
956
|
+
const run = runAgent({
|
|
957
|
+
userMessage, history, mode: 'Build', cwd, signal, route, maxSteps, approve,
|
|
958
|
+
botPrompt: () => `${buildBotPrompt(bot, { team, request: userMessage })}\n\n${VOICE_RULES}${extra}`,
|
|
959
|
+
});
|
|
960
|
+
let reply = '';
|
|
961
|
+
const steps = []; // the trajectory, for reflectOnRun after the call turn
|
|
962
|
+
for await (const ev of run) {
|
|
963
|
+
if (ev.type === 'text' && !ev.interim) reply += (reply ? ' ' : '') + ev.text;
|
|
964
|
+
else if (ev.type === 'tool_start' && onStep) onStep({ name: ev.name, args: ev.args || {} });
|
|
965
|
+
else if (ev.type === 'tool_end') {
|
|
966
|
+
steps.push(runStep(ev));
|
|
967
|
+
if (onStep) onStep({ name: ev.name, args: ev.args || {}, done: true, ok: !!ev.ok });
|
|
968
|
+
}
|
|
969
|
+
else if (ev.type === 'error') throw new Error(ev.error || 'The run failed.');
|
|
970
|
+
else if (ev.type === 'aborted') throw new Error('Stopped.');
|
|
971
|
+
else if (ev.type === 'done') break;
|
|
972
|
+
}
|
|
973
|
+
if (signal && signal.aborted) throw new Error('Stopped.'); // hung up or talked over: nothing to say
|
|
974
|
+
return { text: spoken(reply), request: userMessage, reply, steps };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** One agent tool_end event as a trajectory step for reflectOnRun. */
|
|
978
|
+
function runStep(ev) {
|
|
979
|
+
return { name: ev.name, args: ev.args || {}, ok: !!ev.ok, error: ev.ok ? undefined : clip(ev.summary || ev.error || '', 200) };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** What the call screen says while a tool runs. */
|
|
983
|
+
const CALL_STEP = {
|
|
984
|
+
gmail_search: 'Checking your email', gmail_send: 'Sending the email', web_search: 'Searching the web', web_fetch: 'Reading a page',
|
|
985
|
+
read_file: 'Reading a file', write_file: 'Writing a file', edit_file: 'Editing a file', apply_patch: 'Editing files', run: 'Running a command',
|
|
986
|
+
search: 'Searching your files', list_dir: 'Looking through files', slack_post_message: 'Posting to Slack', browser_check: 'Checking the page',
|
|
987
|
+
ask_bot: 'Asking a teammate', todo: 'Planning',
|
|
988
|
+
};
|
|
989
|
+
const callStepLabel = (name) => CALL_STEP[name] || 'Working on it';
|
|
990
|
+
|
|
991
|
+
module.exports = {
|
|
992
|
+
VOICE_RULES, VOICE_RULES_NO_TOOLS, spoken, callTurns, voiceTurn, callStepLabel,
|
|
993
|
+
MAX_MEMORY, MAX_DEPTH, ROLES, TONES, APPROVALS, AVATAR_KEYS, TEMPLATES,
|
|
994
|
+
botsDir, setBotsDir, normalizeBot, normalizeAvatar,
|
|
995
|
+
listBots, getBot, findBot, createBot, createFromTemplate, updateBot, removeBot,
|
|
996
|
+
mergeMemory, addMemory, forget, clearMemory, learnFromTurn,
|
|
997
|
+
MAX_LESSONS, MAX_PLAYBOOKS, MAX_TOOL_TIPS, MAX_OPEN_THREADS,
|
|
998
|
+
mergeExperience, addExperience, forgetExperience, rankPlaybooks, reflectOnRun, runStep,
|
|
999
|
+
buildBotPrompt, teamPrompt, approvalCategory, canSkipApproval,
|
|
1000
|
+
createLock, globalLock, parseResult, delegate, botCard,
|
|
1001
|
+
describePrompt, fromDescription,
|
|
1002
|
+
};
|