ai-agentmanager 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repeats.js ADDED
@@ -0,0 +1,155 @@
1
+ // Requests you keep making: the asks you type (new agents and backlog items, not ones a playbook,
2
+ // trigger or the assistant started) are remembered, the last 500 in ~/.agentmanager/asks.json. When
3
+ // 3 or more read alike (the same words once numbers, quoted names, URLs, issue keys and filler are
4
+ // set aside), the inbox offers to save them as a playbook: the words they share become the request,
5
+ // the part that changes a {{what}} placeholder, and the projects they ran in its projects.
6
+ import crypto from 'node:crypto';
7
+ import { EventEmitter } from 'node:events';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './config.js';
11
+
12
+ export const SUGGEST_AFTER = 3;
13
+ const KEEP = 500;
14
+ const ALIKE = 0.5; // share of distinct words two asks must have in common
15
+ const STOP = new Set('a an and are as at be by do does for from get have in into is it its make me my of on or our please should so that the their them then this to up use we with you your'.split(' '));
16
+ const err = (status, message) => Object.assign(new Error(message), { status });
17
+
18
+ /** The words that say what an ask is about, without the parts that change from one to the next. */
19
+ export function words(text) {
20
+ const w = String(text ?? '')
21
+ .toLowerCase()
22
+ .replace(/https?:\/\/\S+/g, ' ')
23
+ .replace(/(^|\s)["'`“‘][^"'`”’\n]{1,80}["'`”’](?=[\s.,;:!?)]|$)/g, ' ')
24
+ .replace(/\b[a-z][a-z0-9]+-\d+\b/g, ' ')
25
+ .replace(/#\d+|\b\d[\w.]*/g, ' ')
26
+ .split(/[^a-z0-9_]+/)
27
+ .filter((x) => x.length > 1 && !STOP.has(x));
28
+ return [...new Set(w)];
29
+ }
30
+
31
+ export function alike(a, b) {
32
+ if (!a.length || !b.length) return 0;
33
+ const sb = new Set(b);
34
+ const both = a.filter((x) => sb.has(x)).length;
35
+ return both / (a.length + b.length - both);
36
+ }
37
+
38
+ /** Groups of asks that read alike, newest first; each group's seed is its newest ask. */
39
+ export function groups(asks, { threshold = ALIKE } = {}) {
40
+ const out = [];
41
+ // Newest first; asks recorded together keep their order.
42
+ const order = asks.map((a, i) => [a, i]).sort(([x, i], [y, j]) => y.at.localeCompare(x.at) || j - i);
43
+ for (const [ask] of order) {
44
+ const w = words(ask.text);
45
+ if (w.length < 2) continue;
46
+ const g = out.find((x) => alike(x.words, w) >= threshold);
47
+ if (g) g.asks.push(ask);
48
+ else out.push({ words: w, asks: [ask] });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * A playbook request from asks that read alike: the words they all start and end with, around a
55
+ * {{what}} for the part that differs ("Upgrade react to the latest" / "Upgrade vite to the latest" →
56
+ * "Upgrade {{what}} to the latest"). When they share too little, the newest ask as it was written.
57
+ */
58
+ export function template(texts) {
59
+ const split = texts.map((t) => String(t).trim().split(/\s+/));
60
+ if (texts.every((t) => t.trim() === texts[0].trim())) return texts[0].trim();
61
+ const shortest = Math.min(...split.map((s) => s.length));
62
+ const same = (get) => split.every((s) => get(s) === get(split[0]));
63
+ let pre = 0;
64
+ while (pre < shortest && same((s) => s[pre].toLowerCase())) pre++;
65
+ let suf = 0;
66
+ while (suf < shortest - pre && same((s) => s[s.length - 1 - suf].toLowerCase())) suf++;
67
+ if (pre + suf < Math.max(2, Math.ceil(shortest / 2))) return texts[0].trim();
68
+ const head = split[0].slice(0, pre).join(' ');
69
+ const tail = split[0].slice(split[0].length - suf).join(' ');
70
+ return [head, '{{what}}', tail].filter(Boolean).join(' ');
71
+ }
72
+
73
+ /** A short name for the playbook, from its request. */
74
+ export function nameFor(intent) {
75
+ const plain = intent.replace(/\{\{\w+\}\}/g, ' ').replace(/\s+/g, ' ').trim();
76
+ const w = plain.split(' ').slice(0, 6);
77
+ while (w.length > 1 && STOP.has(w[w.length - 1].toLowerCase())) w.pop();
78
+ const name = w.join(' ');
79
+ return (name.charAt(0).toUpperCase() + name.slice(1)).replace(/[.,:;]+$/, '').slice(0, 60) || 'Repeated request';
80
+ }
81
+
82
+ export class Repeats extends EventEmitter {
83
+ constructor({ file = path.join(paths.home, 'asks.json'), suggestAfter = SUGGEST_AFTER, enabled = true, playbooks = () => [], projects = () => ({}) } = {}) {
84
+ super();
85
+ Object.assign(this, { file, suggestAfter, enabled, playbooks, projects });
86
+ try {
87
+ this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
88
+ } catch {
89
+ this.data = {};
90
+ }
91
+ this.data.asks ??= [];
92
+ this.data.dismissed ??= [];
93
+ }
94
+
95
+ save() {
96
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
97
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
98
+ this.changed();
99
+ }
100
+
101
+ changed() {
102
+ this.emit('change', this.view());
103
+ }
104
+
105
+ /** Asks the person typed: [{ text, projectKey }]. */
106
+ record(asks) {
107
+ const at = new Date().toISOString();
108
+ const list = asks.map((a) => ({ text: String(a.text ?? '').trim().slice(0, 2000), projectKey: a.projectKey ?? null, at })).filter((a) => a.text);
109
+ if (!list.length) return;
110
+ this.data.asks = [...this.data.asks, ...list].slice(-KEEP);
111
+ this.save();
112
+ }
113
+
114
+ /** Requests asked for at least suggestAfter times, not already a playbook and not dismissed. */
115
+ suggestions() {
116
+ if (!this.enabled) return [];
117
+ const books = this.playbooks().map((p) => words(p.intent));
118
+ const projects = this.projects();
119
+ const out = [];
120
+ for (const g of groups(this.data.asks)) {
121
+ if (g.asks.length < this.suggestAfter) continue;
122
+ if (books.some((b) => alike(b, g.words) >= ALIKE)) continue;
123
+ if (this.data.dismissed.some((d) => alike(d, g.words) >= ALIKE)) continue;
124
+ const intent = template(g.asks.map((a) => a.text));
125
+ const names = [...new Set(g.asks.map((a) => projects[a.projectKey]).filter(Boolean))];
126
+ out.push({
127
+ key: crypto.createHash('sha1').update([...g.words].sort().join(' ')).digest('hex').slice(0, 12),
128
+ name: nameFor(intent),
129
+ intent,
130
+ projects: names,
131
+ count: g.asks.length,
132
+ examples: [...new Set(g.asks.map((a) => a.text))].slice(0, 3),
133
+ lastAt: g.asks[0].at,
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** "Not now": stop offering this one (asks like it keep counting, but it isn't offered again). */
140
+ dismiss(key) {
141
+ const g = groups(this.data.asks).find((x) => crypto.createHash('sha1').update([...x.words].sort().join(' ')).digest('hex').slice(0, 12) === key);
142
+ if (!g) throw err(404, 'No such suggestion');
143
+ this.data.dismissed = [...this.data.dismissed, g.words].slice(-100);
144
+ this.save();
145
+ return this.view();
146
+ }
147
+
148
+ view() {
149
+ return { suggestions: this.suggestions(), suggestAfter: this.suggestAfter };
150
+ }
151
+
152
+ inbox() {
153
+ return this.suggestions().map((s) => ({ ...s, kind: 'repeat', key: `repeat:${s.key}`, title: `Save "${s.name}" as a playbook?`, priority: 3 }));
154
+ }
155
+ }
package/src/review.js CHANGED
@@ -9,6 +9,18 @@ import { ghBin } from './tracker.js';
9
9
  const err = (status, message) => Object.assign(new Error(message), { status });
10
10
  const MAX_COMMENTS = 100;
11
11
 
12
+ /** A git command that writes the index, retried while another git process (the app's own status refresh) holds its lock. */
13
+ async function locked(args, opts, tries = 5) {
14
+ for (let i = 1; ; i++) {
15
+ try {
16
+ return await git.run('git', args, opts);
17
+ } catch (e) {
18
+ if (i >= tries || !/index\.lock|another git process/i.test(e.message)) throw e;
19
+ await new Promise((r) => setTimeout(r, 300 * i));
20
+ }
21
+ }
22
+ }
23
+
12
24
  /** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
13
25
  export function parseBaseRef(baseRef) {
14
26
  const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
@@ -190,8 +202,8 @@ export class Review {
190
202
  const opts = { cwd: agent.workdir, timeout: 60_000 };
191
203
  const status = await git.run('git', ['status', '--porcelain'], opts);
192
204
  if (!status.trim()) throw err(409, 'Nothing to commit');
193
- await git.run('git', ['add', '-A'], opts);
194
- await git.run('git', ['commit', '-m', message], opts);
205
+ await locked(['add', '-A'], opts);
206
+ await locked(['commit', '-m', message], opts);
195
207
  const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
196
208
  this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
197
209
  this.manager.refresh(agent.id).catch(() => {});
package/src/scorecard.js CHANGED
@@ -156,6 +156,29 @@ export function combine(reply, f, hunks) {
156
156
  }
157
157
 
158
158
  export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
159
+ const IMPROVE_BELOW = 80; // scores under this are named in the Improve message
160
+
161
+ /**
162
+ * Improve: the agent's own scorecard sent back to it before a person reviews the work. The scores
163
+ * to raise (lowest first, with why), the acceptance criteria not met or not verified, and where a
164
+ * reviewer would look.
165
+ */
166
+ export function improveMessage(sc) {
167
+ const weak = DIMENSIONS.filter(([k]) => sc.scores[k] && sc.scores[k].score < IMPROVE_BELOW)
168
+ .sort(([a], [b]) => sc.scores[a].score - sc.scores[b].score)
169
+ .map(([k, label]) => `- ${label} ${sc.scores[k].score}/100: ${[sc.scores[k].why, ...sc.scores[k].notes].filter(Boolean).join('; ')}`);
170
+ const criteria = sc.criteria.filter((c) => c.status !== 'met').map((c) => `- ${c.status === 'not_met' ? 'Not met' : 'Not verified'}: ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
171
+ const look = sc.look.map((l) => `- ${l.file}: ${l.reason}`);
172
+ return [
173
+ `Before this goes to review: the review scorecard rates your changes ${sc.overall ?? '?'}/100 (${RECOMMENDATION[sc.recommendation]}${sc.reasons.length ? `: ${sc.reasons.join('; ')}` : ''}). Improve them where they fall short.`,
174
+ weak.length ? `Scores to raise:\n${weak.join('\n')}` : '',
175
+ criteria.length ? `Acceptance criteria:\n${criteria.join('\n')}` : '',
176
+ look.length ? `Where a reviewer would look first:\n${look.join('\n')}` : '',
177
+ 'Fix what these point at: add or fix tests and run them, meet the criteria, handle the risky spots. Stay within the task, and say in the brief what you left as it is and why. Update the brief when you are done; the changes are scored again then.',
178
+ ]
179
+ .filter(Boolean)
180
+ .join('\n\n');
181
+ }
159
182
 
160
183
  /** Markdown: for Copy / Download, the pull request's description and a PR comment. */
161
184
  export function scorecardMarkdown(sc, title = '') {
package/src/server.js CHANGED
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
12
- import { AgentManager, MACHINE, shellNumber } from './manager.js';
12
+ import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
13
13
  import { Backlog } from './backlog.js';
14
14
  import { Races } from './races.js';
15
15
  import { Review } from './review.js';
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
17
17
  import { Assistant } from './assistant.js';
18
18
  import { Cleanup } from './cleanup.js';
19
19
  import { Landing } from './landing.js';
20
+ import { Autopilot } from './autopilot.js';
20
21
  import { Budgets } from './budgets.js';
21
22
  import { stats } from './stats.js';
22
23
  import { Playbooks } from './playbooks.js';
24
+ import { Repeats } from './repeats.js';
23
25
  import { Updates } from './update.js';
24
26
  import { identity } from './identity.js';
25
27
  import { Studio } from './studio.js';
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
90
92
  const teams = new Teams({ manager, store, config });
91
93
  const cleanup = new Cleanup({ manager, store, config, teams });
92
94
  const landing = new Landing({ manager, store, config });
95
+ // Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
96
+ const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
97
+ manager.inboxSources.push(() => autopilot.inbox());
93
98
  const assistant = new Assistant({ manager, store, config });
94
99
  // Alerts link to the phone address when remote access is on, else the local UI.
95
100
  const notifier = new Notifier({
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
104
109
  const triggers = new Triggers({ manager, store, config, notifier });
105
110
  const budgets = new Budgets({ manager, store, config, notifier });
106
111
  const playbooks = new Playbooks({ manager, store });
112
+ // Requests you keep typing, offered as a playbook.
113
+ const repeats = new Repeats({
114
+ suggestAfter: config.repeats?.suggestAfter,
115
+ enabled: config.repeats?.enabled !== false,
116
+ playbooks: () => playbooks.list(),
117
+ projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
118
+ });
119
+ manager.inboxSources.push(() => repeats.inbox());
107
120
  const memory = new Memory({ manager, config });
108
121
  const updates = new Updates({ config });
109
122
  let remoteServer = null;
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
230
243
  // Agents the assistant's own session starts: its limits apply, and they are marked as its.
231
244
  const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
232
245
  const created = manager.createMany(list, { ...opts, ...extra });
246
+ if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
233
247
  if (extra.startedBy) {
234
248
  for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
235
249
  }
@@ -254,6 +268,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
254
268
  );
255
269
  api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
256
270
  api.post('/agents/:id/scorecard/post', wrap((req) => review.postScorecard(req.params.id)));
271
+ // Improve: its scorecard goes back to the agent; a backlog item in review is reopened with it.
272
+ api.post(
273
+ '/agents/:id/scorecard/improve',
274
+ wrap((req) => {
275
+ const id = manager.mustGet(req.params.id).id;
276
+ const it = backlog.items().find((i) => i.agentId === id && i.status === 'review');
277
+ return manager.improve(id, it ? { send: (note) => backlog.reopen(it.id, { note }) } : {});
278
+ }),
279
+ );
257
280
  api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
258
281
  api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
259
282
  api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
@@ -443,7 +466,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
443
466
  '/backlog',
444
467
  wrap((req) => {
445
468
  const { items, item, ...opts } = req.body ?? {};
446
- return backlog.add(Array.isArray(items) ? items : [item], opts);
469
+ const added = backlog.add(Array.isArray(items) ? items : [item], opts);
470
+ repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
471
+ return added;
447
472
  }),
448
473
  );
449
474
  // Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
@@ -658,9 +683,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
658
683
  };
659
684
  }));
660
685
  api.get('/playbooks', wrap(() => playbooks.list()));
661
- api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
662
- api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
663
- api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
686
+ // Saving a playbook can answer a repeated-request suggestion.
687
+ const withRepeats = (fn) => (req) => {
688
+ const out = fn(req);
689
+ repeats.changed();
690
+ return out;
691
+ };
692
+ api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
693
+ api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
694
+ api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
695
+ api.get('/repeats', wrap(() => repeats.view()));
696
+ api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
664
697
  api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
665
698
  api.get('/stats', wrap((req) => {
666
699
  const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
@@ -713,6 +746,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
713
746
  api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
714
747
  api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
715
748
  api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
749
+ api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
716
750
  api.post(
717
751
  '/shutdown',
718
752
  wrap(() => {
@@ -923,6 +957,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
923
957
  manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
924
958
  reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
925
959
  manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
960
+ repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
926
961
  manager.on('removed', (id) => {
927
962
  broadcast({ t: 'removed', id });
928
963
  live.closeAgent(id).catch(() => {});
@@ -959,7 +994,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
959
994
  const url = new URL(req.url, 'http://x');
960
995
  if (url.pathname === '/ws/events') {
961
996
  eventClients.add(ws);
962
- ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view() }));
997
+ ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view(), repeats: repeats.view() }));
963
998
  ws.on('close', () => eventClients.delete(ws));
964
999
  return;
965
1000
  }
@@ -1040,6 +1075,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
1040
1075
  closing = true;
1041
1076
  cleanup.close();
1042
1077
  landing.close();
1078
+ autopilot.close();
1043
1079
  triggers.close();
1044
1080
  budgets.close();
1045
1081
  playbooks.close();