codeply-cli 0.5.0 → 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/bots.js CHANGED
@@ -6,6 +6,9 @@
6
6
  * for a chat, buildBotPrompt() is added to that run's system prompt. After
7
7
  * each reply, learnFromTurn() asks the model for at most 3 durable facts to
8
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.
9
12
  *
10
13
  * Bots talk to each other through the ask_bot tool. delegate() runs the asked
11
14
  * bot as an isolated sub-run (its own prompt, only the task text it was
@@ -23,6 +26,12 @@ const path = require('path');
23
26
  const MAX_MEMORY = 60;
24
27
  const MAX_FACTS_PER_TURN = 3;
25
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;
26
35
 
27
36
  let dirOverride = null;
28
37
  function botsDir() {
@@ -191,7 +200,14 @@ function normalizeBot(input, existing) {
191
200
  sources: clip(b.sources, 2000),
192
201
  approval: Array.isArray(b.approval) ? [...new Set(b.approval.filter((k) => APPROVALS[k]))] : ALL_APPROVALS.slice(),
193
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 : '',
194
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),
195
211
  template: typeof b.template === 'string' ? b.template : undefined,
196
212
  createdAt: existing ? existing.createdAt : (Number(b.createdAt) || Date.now()),
197
213
  updatedAt: Date.now(),
@@ -353,6 +369,261 @@ Respond with ONLY a JSON object {"facts": ["..."]}. Use an empty list when nothi
353
369
  }
354
370
  }
355
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
+
356
627
  // ─── Prompts ────────────────────────────────────────────────────────────────
357
628
 
358
629
  function approvalLine(bot) {
@@ -374,6 +645,45 @@ The one task, with every bit of context they need (they see nothing else).
374
645
  </task>
375
646
  </codeply:ask_bot>`;
376
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
+
377
687
  /**
378
688
  * The system prompt addition for one bot.
379
689
  * @param {object} bot
@@ -382,6 +692,7 @@ The one task, with every bit of context they need (they see nothing else).
382
692
  * @param {boolean} [o.canDelegate] ask_bot is available in this run
383
693
  * @param {string} [o.askedBy] set for a delegated sub-run: who asked
384
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
385
696
  */
386
697
  function buildBotPrompt(bot, o = {}) {
387
698
  if (!bot) return '';
@@ -398,6 +709,8 @@ function buildBotPrompt(bot, o = {}) {
398
709
  if (memory.length) {
399
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')}`);
400
711
  }
712
+ parts.push(...experienceParts(bot, o.request));
713
+ parts.push(VERIFY_RULES);
401
714
  if (o.askedBy) {
402
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>`);
403
716
  }
@@ -522,7 +835,7 @@ async function delegate(o) {
522
835
  try {
523
836
  reply = await lock.run(o.token, () => o.runBot(target, {
524
837
  task, depth: depth + 1, chain: [...chain, ...(o.caller ? [o.caller.id] : []), target.id],
525
- prompt: buildBotPrompt(target, { team, askedBy: callerName, canDelegate: depth + 1 < MAX_DEPTH }),
838
+ prompt: buildBotPrompt(target, { team, askedBy: callerName, canDelegate: depth + 1 < MAX_DEPTH, request: task }),
526
839
  }), o.signal);
527
840
  } catch (e) {
528
841
  const output = `${target.name} could not finish: ${e.message}`;
@@ -576,14 +889,113 @@ function fromDescription(json, description) {
576
889
  // Irreversible actions always stay behind approval, whatever the model said.
577
890
  for (const k of ['send', 'publish', 'databases']) if (!draft.approval.includes(k)) draft.approval.push(k);
578
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;
579
893
  return draft;
580
894
  }
581
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
+
582
991
  module.exports = {
992
+ VOICE_RULES, VOICE_RULES_NO_TOOLS, spoken, callTurns, voiceTurn, callStepLabel,
583
993
  MAX_MEMORY, MAX_DEPTH, ROLES, TONES, APPROVALS, AVATAR_KEYS, TEMPLATES,
584
994
  botsDir, setBotsDir, normalizeBot, normalizeAvatar,
585
995
  listBots, getBot, findBot, createBot, createFromTemplate, updateBot, removeBot,
586
996
  mergeMemory, addMemory, forget, clearMemory, learnFromTurn,
997
+ MAX_LESSONS, MAX_PLAYBOOKS, MAX_TOOL_TIPS, MAX_OPEN_THREADS,
998
+ mergeExperience, addExperience, forgetExperience, rankPlaybooks, reflectOnRun, runStep,
587
999
  buildBotPrompt, teamPrompt, approvalCategory, canSkipApproval,
588
1000
  createLock, globalLock, parseResult, delegate, botCard,
589
1001
  describePrompt, fromDescription,
package/lib/cloud.mjs CHANGED
@@ -43,6 +43,7 @@ export const checkName = (id) => `craft ${id}`;
43
43
  const MAX_FILE_BYTES = 50 * 1024 * 1024;
44
44
  const MAX_SCAN_BYTES = 2 * 1024 * 1024;
45
45
  const MAX_PROMPT = 20000;
46
+ const MAX_BOT_PROMPT = 30000;
46
47
  const MAX_OUTPUT = 60000;
47
48
  const HISTORY_TURNS = 40;
48
49
 
@@ -522,7 +523,7 @@ async function dispatch(api, repo, base, task, retryMs) {
522
523
  * queue at once; otherwise a new runner is dispatched. Mirror projects push
523
524
  * the latest snapshot first.
524
525
  */
525
- export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
526
+ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
526
527
  const p = getProject(cwd, home);
527
528
  if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
528
529
  prompt = String(prompt || '').trim();
@@ -547,11 +548,21 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
547
548
  baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
548
549
  }
549
550
  const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
551
+ const b = cleanBot(bot);
552
+ const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
553
+ if (b) task.bot = { name: b.name };
554
+ if (extra.kind) task.kind = 'task';
555
+ const files = branchFiles(api, p.repo);
550
556
  const live = await liveRunner(api, p.repo, task.sessionId);
551
557
  if (live) {
552
- await branchFiles(api, p.repo).write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode }, `Craft queue ${task.id}`);
558
+ await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
553
559
  Object.assign(task, { queued: true, enqueuedAt: Date.now() });
554
560
  } else {
561
+ // A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
562
+ if (Object.keys(extra).length) {
563
+ if (baseSha) await files.ensure(baseSha).catch(() => {});
564
+ if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
565
+ }
555
566
  await dispatch(api, p.repo, base, task, retryMs);
556
567
  }
557
568
  saveTask(cwd, home, task);
@@ -834,8 +845,23 @@ export const SESSION_PATHS = {
834
845
  live: (sid) => `live/${sid}.json`,
835
846
  queue: (sid) => `queue/${sid}`,
836
847
  shot: (id, n) => `shots/${id}/${n}.png`,
848
+ // What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
849
+ request: (id) => `requests/${id}.json`,
837
850
  };
838
851
 
852
+ /**
853
+ * A task's optional bot: { name, prompt }, the full prompt the PC built
854
+ * (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
855
+ */
856
+ export function cleanBot(bot) {
857
+ if (!bot || typeof bot !== 'object') return null;
858
+ const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
859
+ if (!prompt) return null;
860
+ return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
861
+ }
862
+ /** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
863
+ const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
864
+
839
865
  /** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
840
866
  export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
841
867
  const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
@@ -929,6 +955,12 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
929
955
  const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
930
956
 
931
957
  let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
958
+ // A bot (and the task kind) comes next to the dispatch, on craft-sessions.
959
+ const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
960
+ if (req && req.json) {
961
+ task = { ...task, bot: req.json.bot, kind: req.json.kind };
962
+ await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
963
+ }
932
964
  const results = [];
933
965
  try {
934
966
  while (task) {
@@ -971,6 +1003,8 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
971
1003
  const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
972
1004
  const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
973
1005
  const sessionId = task.sessionId || '';
1006
+ const bot = cleanBot(task.bot);
1007
+ const kind = cleanKind(task.kind);
974
1008
  const startedAt = Date.now();
975
1009
  const events = [];
976
1010
  const lines = [];
@@ -981,7 +1015,7 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
981
1015
  output: { title: 'Starting', summary: 'Starting...' },
982
1016
  }).catch(() => ({ json: null }))).json : null;
983
1017
 
984
- const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
1018
+ const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
985
1019
  const finish = async (ok, title, summary, result) => {
986
1020
  const baseLen = JSON.stringify(result).length;
987
1021
  const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
@@ -1031,8 +1065,10 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
1031
1065
  await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
1032
1066
  const approve = ciApprove(cwd);
1033
1067
  let answer = ''; let steps = 0; let failure = '';
1034
- const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
1035
- for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps })) {
1068
+ const userMessage = kind === 'task'
1069
+ ? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
1070
+ : `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
1071
+ for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}) })) {
1036
1072
  const rec = recordEvent(ev);
1037
1073
  if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
1038
1074
  // The final answer arrives as the reply; everything else is part of the chat's steps.
@@ -1046,6 +1082,8 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
1046
1082
  if (pending) await pending;
1047
1083
  if (failure && !answer) throw new Error(failure);
1048
1084
 
1085
+ // The bot's own last words, before a plan file replaces the answer: what the phone speaks.
1086
+ const reply = answer;
1049
1087
  if (mode === 'Plan') {
1050
1088
  const dir = path.join(cwd, '.codeply', 'plans');
1051
1089
  const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
@@ -1088,7 +1126,8 @@ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl,
1088
1126
 
1089
1127
  await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
1090
1128
  .catch((e) => log(`Could not save the chat: ${e.message}`));
1091
- const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase };
1129
+ const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase,
1130
+ ...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
1092
1131
  const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
1093
1132
  return finish(true, title, answer || title, result);
1094
1133
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeply-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Codeply from the terminal: an interactive AI coding agent, a local server (codeply serve), and a GitHub agent that runs in your own Actions runner.",
5
5
  "main": "bin/codeply.js",
6
6
  "bin": {