codeply-cli 0.4.0 → 0.4.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.
Files changed (2) hide show
  1. package/lib/cloud.mjs +97 -9
  2. package/package.json +1 -1
package/lib/cloud.mjs CHANGED
@@ -495,9 +495,13 @@ export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(),
495
495
  const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
496
496
  const check = (checks.check_runs || [])[0];
497
497
  if (check && check.output) next.progress = check.output.summary || '';
498
+ // The step-by-step record rides along to the caller but isn't kept in cloud.json.
499
+ let events = [];
500
+ let data = {};
501
+ try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
502
+ if (Array.isArray(data.events)) events = data.events;
498
503
  if (check && check.status === 'completed') {
499
- let result = {};
500
- try { result = JSON.parse(check.output.text || '{}'); } catch {}
504
+ const { events: _drop, ...result } = data;
501
505
  Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
502
506
  } else if (run.status === 'completed') {
503
507
  const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
@@ -506,7 +510,7 @@ export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(),
506
510
  next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
507
511
  }
508
512
  saveTask(cwd, home, next);
509
- return next;
513
+ return { ...next, events };
510
514
  }
511
515
 
512
516
  /**
@@ -528,6 +532,7 @@ export async function importRemoteTasks({ cwd, token, home = defaultHome(), apiU
528
532
  if (!check || check.status !== 'completed') continue;
529
533
  let result = {};
530
534
  try { result = JSON.parse(check.output.text || '{}'); } catch {}
535
+ delete result.events;
531
536
  const task = {
532
537
  id, prompt: result.prompt || '(started elsewhere)', mode: result.mode || 'Build', sessionId: result.sessionId || '', baseSha: result.baseSha || run.head_sha,
533
538
  repo: p.repo, runId: run.id, runUrl: run.html_url, status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null,
@@ -592,6 +597,71 @@ function renderProgress(lines, note) {
592
597
  return `${note}\n\n${shown.join('\n')}`.slice(0, MAX_OUTPUT);
593
598
  }
594
599
 
600
+ // ─── The run as a chat: every step the agent took, published live ─────────
601
+ // Cloud chats look like local ones (commands, edits with their + and - lines,
602
+ // narration, the "what actually happened" card), so the runner records the
603
+ // same events the desktop gets from a local run and puts them in the check
604
+ // run's text, where the PC and the phone read them while it works.
605
+
606
+ const EVENT_BUDGET = 58000;
607
+ const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n...` : s);
608
+
609
+ /** One agent event as a small record; tool arguments kept the way the chat shows them. */
610
+ export function recordEvent(ev) {
611
+ if (ev.type === 'text') return { t: 'text', text: clipText(ev.text, 8000), interim: !!ev.interim };
612
+ if (ev.type === 'reasoning') return { t: 'reasoning', text: clipText(ev.text, 2000), ms: ev.ms };
613
+ if (ev.type === 'notice') return { t: 'notice', level: ev.level || 'info', text: clipText(ev.text, 1000) };
614
+ if (ev.type === 'tool_end') {
615
+ const a = ev.args || {};
616
+ let args;
617
+ if (ev.name === 'write_file') args = { path: a.path };
618
+ else if (ev.name === 'apply_patch') args = { files: (ev.meta && ev.meta.files) || [] };
619
+ else {
620
+ args = {};
621
+ for (const [k, v] of Object.entries(a)) {
622
+ if (typeof v === 'string') args[k] = clipText(v, k === 'search' || k === 'replace' ? 3000 : 1500);
623
+ else if (typeof v === 'number' || typeof v === 'boolean') args[k] = v;
624
+ else if (Array.isArray(v)) args[k] = v.slice(0, 50);
625
+ }
626
+ }
627
+ const m = ev.meta || {};
628
+ return {
629
+ t: 'tool', name: ev.name, ok: ev.ok, summary: clipText(ev.summary || '', 300), args,
630
+ ...(typeof m.exitCode === 'number' ? { exitCode: m.exitCode } : {}),
631
+ ...(typeof m.added === 'number' ? { added: m.added } : {}),
632
+ ...(typeof m.removed === 'number' ? { removed: m.removed } : {}),
633
+ };
634
+ }
635
+ if (ev.type === 'done' && Array.isArray(ev.actions)) {
636
+ const changed = ev.actions.filter((x) => x.ok && ['write_file', 'edit_file', 'apply_patch', 'fetch_image'].includes(x.tool))
637
+ .flatMap((x) => (Array.isArray(x.files) && x.files.length ? x.files.map((f) => ({ ...x, label: f })) : [x]));
638
+ const checks = ev.actions.filter((x) => ['run', 'browser_check'].includes(x.tool));
639
+ if (!changed.length && !checks.length) return null;
640
+ return {
641
+ t: 'summary', files: [...new Set(changed.map((x) => x.label))].slice(0, 30),
642
+ checks: [...new Map(checks.map((x) => [`${x.tool}|${x.label}`, x])).values()].slice(-8).map((x) => ({ tool: x.tool, label: x.label, ok: x.ok, exitCode: x.exitCode })),
643
+ unverified: ev.unverifiedFiles || [],
644
+ };
645
+ }
646
+ return null;
647
+ }
648
+
649
+ /** Fit the event list in the check run's text: shorten diffs, drop thinking, then the oldest steps. */
650
+ export function packEvents(events, budget = EVENT_BUDGET) {
651
+ let list = events.map((e) => ({ ...e }));
652
+ const size = () => JSON.stringify(list).length;
653
+ if (size() <= budget) return list;
654
+ list = list.map((e) => (e.t === 'reasoning' ? { ...e, text: clipText(e.text, 200) } : e));
655
+ for (const n of [800, 200]) {
656
+ if (size() <= budget) return list;
657
+ list = list.map((e) => (e.t === 'tool' && e.args ? { ...e, args: Object.fromEntries(Object.entries(e.args).map(([k, v]) => [k, typeof v === 'string' ? clipText(v, n) : v])) } : e));
658
+ }
659
+ let dropped = 0;
660
+ while (size() > budget && list.length > 1) { list.shift(); dropped++; }
661
+ if (dropped) list.unshift({ t: 'notice', level: 'info', text: `${dropped} earlier step${dropped === 1 ? '' : 's'} are only in the run log.` });
662
+ return list;
663
+ }
664
+
595
665
  async function loadHistory(api, repo, sessionId) {
596
666
  if (!sessionId) return { messages: [], sha: null };
597
667
  const r = await api('GET', `/repos/${repo}/contents/sessions/${encodeURIComponent(sessionId)}.json?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
@@ -635,12 +705,16 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
635
705
  name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
636
706
  output: { title: 'Starting', summary: 'Starting...' },
637
707
  })).json;
708
+ const events = [];
638
709
  const finish = async (ok, title, summary, result) => {
710
+ // The result's fields stay at the top level (older apps read them there); events share the space left.
711
+ const base = JSON.stringify(result);
712
+ const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - base.length - 200));
639
713
  await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
640
714
  status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
641
- output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify(result).slice(0, MAX_OUTPUT) },
715
+ output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
642
716
  }).catch((e) => log(`Could not finish the check run: ${e.message}`));
643
- return { status: ok ? 'done' : 'failed', result };
717
+ return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
644
718
  };
645
719
 
646
720
  if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
@@ -651,7 +725,9 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
651
725
  const pushProgress = async (note, force = false) => {
652
726
  if (!force && Date.now() - lastPush < updateMs) return;
653
727
  lastPush = Date.now();
654
- pending = api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note) } }).catch(() => {});
728
+ pending = api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
729
+ output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packEvents(events) }) },
730
+ }).catch(() => {});
655
731
  await pending;
656
732
  };
657
733
 
@@ -662,9 +738,13 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
662
738
  let answer = ''; let steps = 0; let failure = '';
663
739
  const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud, on a copy of the project. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Do not commit or push; Craft does that when you finish.)`;
664
740
  for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, signal: new AbortController().signal, route, maxSteps })) {
741
+ const rec = recordEvent(ev);
742
+ // The final answer arrives as the reply; everything else is part of the chat's steps.
743
+ if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
665
744
  if (ev.type === 'text' && !ev.interim) answer = ev.text;
666
- else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`); }
745
+ else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); }
667
746
  else if (ev.type === 'error') failure = ev.error;
747
+ if (rec) await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`);
668
748
  if (ev.type === 'done' || ev.type === 'error' || ev.type === 'aborted') break;
669
749
  }
670
750
  if (pending) await pending;
@@ -676,22 +756,30 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
676
756
  if (files.length) answer = fs.readFileSync(path.join(dir, files[0].f), 'utf8');
677
757
  }
678
758
 
679
- let branch = null; let sha = null; let files = [];
759
+ let branch = null; let sha = null; let files = []; let stats = [];
680
760
  if (mode === 'Build') {
681
761
  await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`]);
682
762
  const staged = await git(cwd, ['diff', '--cached', '--name-only']);
683
763
  if (staged) {
684
764
  files = staged.split('\n').filter(Boolean);
765
+ // Per-file + and - lines, for the chat's record of what was pushed.
766
+ stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
767
+ const [add, del, ...name] = l.split('\t');
768
+ return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
769
+ });
685
770
  branch = taskBranch(taskId);
686
771
  await git(cwd, [...BOT, 'commit', '-q', '-m', `${prompt.split('\n')[0].slice(0, 60)}\n\nCraft cloud task ${taskId}.`]);
687
772
  sha = await git(cwd, ['rev-parse', 'HEAD']);
688
773
  await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
774
+ const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
775
+ const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
776
+ events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus });
689
777
  }
690
778
  }
691
779
 
692
780
  await saveHistory(api, repo, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2), headSha)
693
781
  .catch((e) => log(`Could not save the chat: ${e.message}`));
694
- const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 40000), branch, sha, files, steps, baseSha: headSha };
782
+ const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files, stats: stats.slice(0, 100), steps, baseSha: headSha };
695
783
  const title = files.length ? `Changed ${files.length} file${files.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
696
784
  return finish(true, title, answer || title, result);
697
785
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeply-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.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": {