ai-agentmanager 1.13.0 → 1.15.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/CHANGELOG.md +13 -0
- package/README.md +26 -1
- package/docs/agentkit.md +1 -1
- package/package.json +1 -1
- package/public/app.js +124 -7
- package/public/index.html +5 -0
- package/public/menu.js +18 -0
- package/public/style.css +10 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +19 -7
- package/src/builder.js +36 -15
- package/src/cli.js +57 -2
- package/src/manager.js +63 -2
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/rules.js +161 -0
- package/src/server.js +44 -6
- package/src/stuck.js +9 -2
package/src/backlog.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
import { parsePriority, projectOf } from './manager.js';
|
|
6
|
+
import { AUTOPILOT_LEVELS } from './autopilot.js';
|
|
6
7
|
import { dispatchPrompt, hash } from './judge.js';
|
|
7
8
|
|
|
8
9
|
const now = () => new Date().toISOString();
|
|
@@ -19,6 +20,7 @@ export class Backlog extends EventEmitter {
|
|
|
19
20
|
this.config = config;
|
|
20
21
|
store.data.backlog ??= { items: {}, limits: {}, autoRun: true };
|
|
21
22
|
this.data = store.data.backlog;
|
|
23
|
+
this.data.autopilot ??= {};
|
|
22
24
|
this.dispatching = false;
|
|
23
25
|
this.plan = null; // the AI judge's dispatch plan: { hash, data, pending }
|
|
24
26
|
|
|
@@ -38,6 +40,12 @@ export class Backlog extends EventEmitter {
|
|
|
38
40
|
return this.data.limits[projectKey] ?? this.defaultLimit();
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** Autopilot for a project's items (see autopilot.js): "off", "pr" or "merge". */
|
|
44
|
+
autopilotFor(projectKey) {
|
|
45
|
+
const level = this.data.autopilot[projectKey] ?? this.config.autopilot?.level;
|
|
46
|
+
return AUTOPILOT_LEVELS.includes(level) ? level : 'off';
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
items() {
|
|
42
50
|
return Object.values(this.data.items).sort(
|
|
43
51
|
(a, b) => a.priority - b.priority || a.order - b.order || a.createdAt.localeCompare(b.createdAt),
|
|
@@ -63,7 +71,7 @@ export class Backlog extends EventEmitter {
|
|
|
63
71
|
const projects = {};
|
|
64
72
|
for (const it of items) {
|
|
65
73
|
const shortcut = Object.values(this.store.data.projects ?? {}).find((x) => x.key === it.projectKey)?.shortcut ?? null;
|
|
66
|
-
const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), counts: {} });
|
|
74
|
+
const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), autopilot: this.autopilotFor(it.projectKey), counts: {} });
|
|
67
75
|
p.counts[it.status] = (p.counts[it.status] ?? 0) + 1;
|
|
68
76
|
}
|
|
69
77
|
return {
|
|
@@ -180,14 +188,18 @@ export class Backlog extends EventEmitter {
|
|
|
180
188
|
return { id };
|
|
181
189
|
}
|
|
182
190
|
|
|
183
|
-
settings({ autoRun, limits, project, limit } = {}) {
|
|
191
|
+
settings({ autoRun, limits, project, limit, autopilot } = {}) {
|
|
184
192
|
if (typeof autoRun === 'boolean') this.data.autoRun = autoRun;
|
|
185
193
|
if (limits && typeof limits === 'object') Object.assign(this.data.limits, limits);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
|
|
190
|
-
|
|
194
|
+
const key =
|
|
195
|
+
project === undefined
|
|
196
|
+
? null
|
|
197
|
+
: this.manager.resolveProject(project)?.key ?? this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ?? project;
|
|
198
|
+
if (key && autopilot !== undefined) {
|
|
199
|
+
if (!AUTOPILOT_LEVELS.includes(autopilot)) throw err(400, `autopilot must be one of ${AUTOPILOT_LEVELS.join(', ')}`);
|
|
200
|
+
this.data.autopilot[key] = autopilot;
|
|
201
|
+
}
|
|
202
|
+
if (key && limit !== undefined) {
|
|
191
203
|
const n = Number(limit);
|
|
192
204
|
if (!(n >= 0 && n <= 20)) throw err(400, 'limit must be 0-20');
|
|
193
205
|
this.data.limits[key] = n;
|
package/src/builder.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// downloads it here. Built agents live in ~/.agentmanager/built/<name>/ as ordinary agentkit folders.
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import YAML from 'yaml';
|
|
@@ -127,6 +128,19 @@ export function filesWritten(dir, before, { root = dir, limit = 6, bytes = 4000
|
|
|
127
128
|
return out;
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A throwaway copy of the agent's folder for one test run, so what a run writes (outputs, memory,
|
|
133
|
+
* changed fixtures) doesn't change the agent or the next test. It keeps the agent's memory, not its
|
|
134
|
+
* engine sessions, run logs, executables, dependencies or git data.
|
|
135
|
+
*/
|
|
136
|
+
export function testCopy(dir) {
|
|
137
|
+
const copy = fs.mkdtempSync(path.join(os.tmpdir(), `agentkit-test-${path.basename(dir)}-`));
|
|
138
|
+
const state = path.join(dir, '.agentkit');
|
|
139
|
+
const keep = (src) => src === dir || (path.dirname(src) === state ? path.basename(src) === 'memory.md' : src === state || !SKIP.has(path.basename(src)));
|
|
140
|
+
fs.cpSync(dir, copy, { recursive: true, filter: keep });
|
|
141
|
+
return copy;
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
export class Builder {
|
|
131
145
|
/** `oneShot(prompt)` answers a judge prompt (the daemon's writer engine). */
|
|
132
146
|
/** `sessions`: the daemon's agent sessions ({ create, get, tell, stop }); each built agent is developed in one. */
|
|
@@ -324,25 +338,32 @@ export class Builder {
|
|
|
324
338
|
this.pending.set(name, 'testing');
|
|
325
339
|
this.saveMeta(name, { status: 'testing' });
|
|
326
340
|
try {
|
|
327
|
-
//
|
|
328
|
-
|
|
341
|
+
// Each test runs in its own copy of the agent's folder (a workdir outside it is used in place),
|
|
342
|
+
// and the judge sees what the run wrote there, not only what it says it did. Tests don't wait
|
|
343
|
+
// for people: approval tools are simply not available in them.
|
|
329
344
|
const results = [];
|
|
330
|
-
// The judge sees what each run wrote in the agent's folders, not only what it says it did.
|
|
331
|
-
const roots = [...new Set([dir, spec.workdir])].filter((d, i, all) => !all.some((o, j) => j !== i && d.startsWith(o + path.sep)));
|
|
332
345
|
for (const t of tests) {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
346
|
+
const copy = testCopy(dir);
|
|
347
|
+
try {
|
|
348
|
+
const run = loadAgent(copy, { env: this.env });
|
|
349
|
+
const agent = new Agent({ ...run, triggers: [], report: null }, { env: this.env, engineTurn: this.runTurn, approvals: 'deny' });
|
|
350
|
+
const roots = [...new Set([copy, run.workdir])].filter((d, i, all) => !all.some((o, j) => j !== i && d.startsWith(o + path.sep)));
|
|
351
|
+
const before = roots.map((d) => snapshot(d));
|
|
352
|
+
const r = await agent.run(t.input, { source: 'test' });
|
|
353
|
+
const files = roots.flatMap((d, i) => filesWritten(d, before[i], { root: copy })).slice(0, 6);
|
|
354
|
+
let verdict = { pass: false, why: 'The run failed' };
|
|
355
|
+
if (r.status !== 'failed') {
|
|
356
|
+
try {
|
|
357
|
+
const v = parseJsonReply(await this.oneShot(judgePrompt({ goal: spec.goal, input: t.input, expect: t.expect, result: r.result, files })));
|
|
358
|
+
verdict = { pass: v.pass === true, why: String(v.why ?? '') };
|
|
359
|
+
} catch (e) {
|
|
360
|
+
verdict = { pass: false, why: `Could not judge: ${e.message}` };
|
|
361
|
+
}
|
|
343
362
|
}
|
|
363
|
+
results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, files: files.map((f) => f.path), ...verdict });
|
|
364
|
+
} finally {
|
|
365
|
+
fs.rmSync(copy, { recursive: true, force: true });
|
|
344
366
|
}
|
|
345
|
-
results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, files: files.map((f) => f.path), ...verdict });
|
|
346
367
|
}
|
|
347
368
|
const passed = results.filter((r) => r.pass).length;
|
|
348
369
|
this.saveMeta(name, { results: { at: now(), passed, total: results.length, list: results }, status: passed === results.length ? 'tested' : 'failing' });
|
package/src/cli.js
CHANGED
|
@@ -879,6 +879,8 @@ export async function main(argv) {
|
|
|
879
879
|
await attach(ref, { kind: `shell:${n}` });
|
|
880
880
|
});
|
|
881
881
|
|
|
882
|
+
const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/');
|
|
883
|
+
|
|
882
884
|
program
|
|
883
885
|
.command('inbox')
|
|
884
886
|
.description('Everything waiting on you: prompts, questions, stuck agents, overlapping changes')
|
|
@@ -888,6 +890,21 @@ export async function main(argv) {
|
|
|
888
890
|
if (!items.length) return console.log(dim('Nothing needs you right now.'));
|
|
889
891
|
const label = { prompt: red('needs input'), waiting: red('waiting'), question: cyan('question'), review: cyan('AI review'), stuck: yellow('stuck?'), conflict: yellow('overlap') };
|
|
890
892
|
for (const it of items) {
|
|
893
|
+
if (it.kind === 'repeat') {
|
|
894
|
+
console.log(`${cyan('make it a playbook?')} asked ${it.count} times: ${it.intent}`);
|
|
895
|
+
console.log(dim(` agentmgr playbook add "${it.name}" "${it.intent.replace(/"/g, '\\"')}" --projects ${it.projects.join(',') || '<project>'}`));
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (it.kind === 'autopilot') {
|
|
899
|
+
console.log(`${yellow('autopilot held')} ${bold(it.project ?? '')} ${it.title}`);
|
|
900
|
+
console.log(dim(` ${it.note ?? ''}\n agentmgr backlog ship ${it.itemId} · agentmgr changes ${it.agentId}`));
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (it.kind === 'rule') {
|
|
904
|
+
console.log(`${cyan('always allow?')} ${bold(repoLabel(it.repo))} ${it.title} ${dim(`(allowed ${it.n} times)`)}`);
|
|
905
|
+
console.log(dim(' agentmgr rules'));
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
891
908
|
console.log(`${label[it.kind]} ${bold(it.agentName)} ${dim(it.agentId)} ${it.title}`);
|
|
892
909
|
if (it.kind === 'prompt') console.log(dim(` ${(it.prompt?.choices ?? []).map((c) => `${c.key}. ${c.label}`).join(' ')} → agentmgr attach ${it.agentId}`));
|
|
893
910
|
if (it.kind === 'question') {
|
|
@@ -901,6 +918,32 @@ export async function main(argv) {
|
|
|
901
918
|
}
|
|
902
919
|
});
|
|
903
920
|
|
|
921
|
+
program
|
|
922
|
+
.command('rules')
|
|
923
|
+
.description('Approval rules: commands always allowed per repo, and the ones the inbox suggests')
|
|
924
|
+
.argument('[action]', 'allow <n> | no <n> (suggestions), rm <n> (allowed)')
|
|
925
|
+
.argument('[n]')
|
|
926
|
+
.action(async (action, n) => {
|
|
927
|
+
await daemon();
|
|
928
|
+
let r = await api('GET', '/rules');
|
|
929
|
+
const pick = (list) => {
|
|
930
|
+
const x = list[Number(n) - 1];
|
|
931
|
+
if (!x) throw new Error(`No rule ${n ?? ''}; see agentmgr rules`);
|
|
932
|
+
return { repo: x.repo, rule: x.rule };
|
|
933
|
+
};
|
|
934
|
+
if (action === 'allow') r = await api('POST', '/rules/accept', pick(r.suggestions));
|
|
935
|
+
else if (action === 'no') r = await api('POST', '/rules/dismiss', pick(r.suggestions));
|
|
936
|
+
else if (action === 'rm') r = await api('POST', '/rules/remove', pick(r.allowed));
|
|
937
|
+
else if (action) throw new Error('Use: agentmgr rules [allow <n> | no <n> | rm <n>]');
|
|
938
|
+
if (r.suggestions.length) {
|
|
939
|
+
console.log(bold('Suggested') + dim(` (you allowed these ${r.suggestAfter}+ times; agentmgr rules allow <n> | no <n>)`));
|
|
940
|
+
r.suggestions.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}, allowed ${x.n} times`)}`));
|
|
941
|
+
}
|
|
942
|
+
console.log(bold('Allowed') + dim(' (agents in the repo run these without asking; agentmgr rules rm <n>)'));
|
|
943
|
+
if (!r.allowed.length) console.log(dim(' none yet'));
|
|
944
|
+
r.allowed.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}${x.uses ? `, answered ${x.uses} prompts` : ''}`)}`));
|
|
945
|
+
});
|
|
946
|
+
|
|
904
947
|
program
|
|
905
948
|
.command('answer')
|
|
906
949
|
.description('Answer a question an agent asked in the inbox')
|
|
@@ -1211,15 +1254,18 @@ export async function main(argv) {
|
|
|
1211
1254
|
const items = v.items.filter((i) => all || !['done', 'cancelled'].includes(i.status));
|
|
1212
1255
|
console.log(`${v.autoRun ? green('running') : yellow('paused')} ${dim(`${v.defaultLimit} per project by default, ${v.maxActive} overall`)}`);
|
|
1213
1256
|
if (!items.length) return console.log(dim('Backlog is empty. Add: agentmgr backlog add "ask" -r <repo>'));
|
|
1214
|
-
const
|
|
1257
|
+
const proj = Object.fromEntries(v.projects.map((p) => [p.key, p]));
|
|
1215
1258
|
let last = null;
|
|
1216
1259
|
for (const it of items) {
|
|
1217
1260
|
if (it.projectKey !== last) {
|
|
1218
|
-
|
|
1261
|
+
const p = proj[it.projectKey];
|
|
1262
|
+
const ap = p?.autopilot && p.autopilot !== 'off' ? `, autopilot: ${p.autopilot === 'merge' ? 'merge' : 'open PRs'}` : '';
|
|
1263
|
+
console.log(`\n${bold(it.projectName)} ${dim(`(${p?.limit ?? v.defaultLimit} at a time${ap})`)}`);
|
|
1219
1264
|
last = it.projectKey;
|
|
1220
1265
|
}
|
|
1221
1266
|
const agent = it.agent ? dim(` → ${it.agent.name} (${it.agent.id})${it.agent.briefStatus ? ` brief: ${it.agent.briefStatus}` : ''}`) : '';
|
|
1222
1267
|
console.log(` ${it.id} P${it.priority} ${(ST[it.status] ?? it.status).padEnd(18)} ${truncate(it.title, 60)}${agent}`);
|
|
1268
|
+
if (it.status === 'review' && it.autopilot?.state) console.log(dim(` autopilot: ${it.autopilot.state}${it.autopilot.pr ? ` ${it.autopilot.pr}` : ''}${it.autopilot.note ? `: ${it.autopilot.note}` : ''}`));
|
|
1223
1269
|
}
|
|
1224
1270
|
};
|
|
1225
1271
|
bl.command('ls')
|
|
@@ -1307,6 +1353,15 @@ export async function main(argv) {
|
|
|
1307
1353
|
await daemon();
|
|
1308
1354
|
printBacklog(await api('POST', '/backlog/settings', { project, limit: Number(n) }));
|
|
1309
1355
|
});
|
|
1356
|
+
bl.command('autopilot')
|
|
1357
|
+
.description("Take a project's finished items on by themselves when the review scorecard says ready: off, pr (open the pull request) or merge (and merge it once checks pass)")
|
|
1358
|
+
.argument('<project>', 'project name as shown in "backlog ls"')
|
|
1359
|
+
.argument('<level>', 'off, pr or merge')
|
|
1360
|
+
.action(async (project, level) => {
|
|
1361
|
+
await daemon();
|
|
1362
|
+
printBacklog(await api('POST', '/backlog/settings', { project, autopilot: level }));
|
|
1363
|
+
});
|
|
1364
|
+
itemCmd('ship', 'Go ahead with an item autopilot held: open its pull request (and merge, at the merge level)', 'ship');
|
|
1310
1365
|
bl.command('pause')
|
|
1311
1366
|
.description('Stop starting new items (active ones continue)')
|
|
1312
1367
|
.action(async () => {
|
package/src/manager.js
CHANGED
|
@@ -112,6 +112,7 @@ import { Plugins } from './plugins.js';
|
|
|
112
112
|
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
113
113
|
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
114
114
|
import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
|
|
115
|
+
import { Rules, describeTool } from './rules.js';
|
|
115
116
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
116
117
|
import { builderAllowedTools, builderPrompt } from './builder.js';
|
|
117
118
|
import {
|
|
@@ -149,6 +150,8 @@ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-
|
|
|
149
150
|
// When a link is found from several sources, the higher-ranked source is kept.
|
|
150
151
|
const SOURCE_RANK = { manual: 5, intent: 4, session: 3, branch: 2, mentioned: 1 };
|
|
151
152
|
const BUSY_IDLE_MS = 3000;
|
|
153
|
+
// The attention label engines give a tool permission menu (what rules can answer).
|
|
154
|
+
const PERMISSION = 'Permission prompt';
|
|
152
155
|
/** Looser stuck thresholds: candidates the judge looks at before anyone is bothered. */
|
|
153
156
|
const loosen = (o) => ({
|
|
154
157
|
repeatFailures: Math.max(2, Math.ceil(o.repeatFailures / 2)),
|
|
@@ -282,6 +285,8 @@ export class AgentManager extends EventEmitter {
|
|
|
282
285
|
this.stuck = new Map(); // agent id -> [finding]
|
|
283
286
|
this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
|
|
284
287
|
// The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
|
|
288
|
+
this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
|
|
289
|
+
this.inboxSources = [];
|
|
285
290
|
this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
|
|
286
291
|
this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
|
|
287
292
|
this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
|
|
@@ -373,7 +378,7 @@ export class AgentManager extends EventEmitter {
|
|
|
373
378
|
shell: this.shellNumbers(agent.id).length > 0,
|
|
374
379
|
shells: this.shellNumbers(agent.id),
|
|
375
380
|
lastOutputAt: rt?.lastOutputAt ? new Date(rt.lastOutputAt).toISOString() : null,
|
|
376
|
-
prompt: rt?.pty && !rt.busy && rt.attention ?
|
|
381
|
+
prompt: rt?.pty && !rt.busy && rt.attention ? this.promptView(agent, rt) : null,
|
|
377
382
|
},
|
|
378
383
|
};
|
|
379
384
|
}
|
|
@@ -1066,6 +1071,9 @@ export class AgentManager extends EventEmitter {
|
|
|
1066
1071
|
prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
|
|
1067
1072
|
allowedTools = [`Bash(${learnCommand()}:*)`];
|
|
1068
1073
|
}
|
|
1074
|
+
// Commands you chose to always allow in this repo.
|
|
1075
|
+
const ruled = agent.workdir ? this.rules.allowed(memoryKey(agent)) : [];
|
|
1076
|
+
if (ruled.length) allowedTools = [...(allowedTools ?? []), ...ruled];
|
|
1069
1077
|
if (agent.builds && agent.workdir) {
|
|
1070
1078
|
prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
|
|
1071
1079
|
// Checking the agent loads and reading the reference need no permission prompt.
|
|
@@ -2866,16 +2874,68 @@ export class AgentManager extends EventEmitter {
|
|
|
2866
2874
|
items.push({ ...base, kind: 'conflict', key: `conflict:${agent.id}:${g.agent.id}`, title: `Overlaps with ${g.agent.name}`, conflict: g });
|
|
2867
2875
|
}
|
|
2868
2876
|
}
|
|
2869
|
-
|
|
2877
|
+
// Commands you keep allowing in a repo: offer to always allow them there.
|
|
2878
|
+
for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
|
|
2879
|
+
// Others' items (autopilot's held backlog work).
|
|
2880
|
+
for (const source of this.inboxSources) items.push(...source());
|
|
2881
|
+
const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
|
|
2870
2882
|
return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
|
|
2871
2883
|
}
|
|
2872
2884
|
|
|
2885
|
+
/** A live prompt's question and choices, and the tool call it asks about when the transcript says. */
|
|
2886
|
+
promptView(agent, rt) {
|
|
2887
|
+
const p = engineOf(agent).promptChoices(rt.tail);
|
|
2888
|
+
const call = rt.attention === PERMISSION ? this.pendingCall(agent) : null;
|
|
2889
|
+
return call ? { ...p, tool: describeTool(call) } : p;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/** The one tool call a live prompt is waiting on, from the transcript (Claude Code); null when unsure. */
|
|
2893
|
+
pendingCall(agent) {
|
|
2894
|
+
const engine = engineOf(agent);
|
|
2895
|
+
if (engine.id !== 'claude' || !agent.workdir || !agent.sessionId) return null;
|
|
2896
|
+
const key = `${agent.sessionId}:${agent.workdir}`;
|
|
2897
|
+
const state = this.activity.get(key) ?? {};
|
|
2898
|
+
this.activity.set(key, engine.activity(engine.sessionFile(agent.workdir, agent.sessionId), state));
|
|
2899
|
+
return state.calls?.size === 1 ? [...state.calls.values()][0] : null;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
/** A permission prompt for a call one of the repo's allowed rules covers is answered yes by itself. */
|
|
2903
|
+
autoAllow(agent, rt) {
|
|
2904
|
+
if (this.config.rules?.auto === false) return false;
|
|
2905
|
+
const call = this.pendingCall(agent);
|
|
2906
|
+
const repo = call ? memoryKey(agent) : null;
|
|
2907
|
+
const rule = call && this.rules.allows(repo, call);
|
|
2908
|
+
if (!rule) return false;
|
|
2909
|
+
const first = engineOf(agent).promptChoices(rt.tail)?.choices?.[0];
|
|
2910
|
+
if (first?.key !== '1' || !/^yes\b/i.test(first.label)) return false;
|
|
2911
|
+
this.write(agent.id, '1');
|
|
2912
|
+
rt.attention = null;
|
|
2913
|
+
this.rules.used(repo, rule);
|
|
2914
|
+
this.plugins.emit('agent.prompt.allowed', { agent: this.auditView(agent), rule, tool: describeTool(call) });
|
|
2915
|
+
return true;
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
/** After a rule is allowed: prompts already waiting on a call it covers are answered too. */
|
|
2919
|
+
applyRules() {
|
|
2920
|
+
for (const [id, rt] of this.runtimes) {
|
|
2921
|
+
const agent = this.store.get(id);
|
|
2922
|
+
if (agent && rt.pty && !rt.busy && rt.attention === PERMISSION && this.autoAllow(agent, rt)) this.changed(agent);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2873
2926
|
/** Send keys to a live prompt (e.g. "1" to pick the first option). */
|
|
2874
2927
|
answerPrompt(ref, key) {
|
|
2875
2928
|
const agent = this.mustGet(ref);
|
|
2876
2929
|
const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
|
|
2877
2930
|
const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
|
|
2878
2931
|
if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
|
|
2932
|
+
// A yes to a permission prompt counts toward offering a rule for it in this repo.
|
|
2933
|
+
const rt = this.runtimes.get(agent.id);
|
|
2934
|
+
if (rt?.pty && !rt.busy && rt.attention === PERMISSION) {
|
|
2935
|
+
const choice = engineOf(agent).promptChoices(rt.tail)?.choices?.find((c) => c.key === key);
|
|
2936
|
+
const call = /^yes\b/i.test(choice?.label ?? '') ? this.pendingCall(agent) : null;
|
|
2937
|
+
if (call) this.rules.approved(memoryKey(agent), call);
|
|
2938
|
+
}
|
|
2879
2939
|
// Menus that read a line (the ACP client's) take Enter after the choice.
|
|
2880
2940
|
this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
|
|
2881
2941
|
return { ok: true };
|
|
@@ -2985,6 +3045,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2985
3045
|
const screen = rt.tail.slice(-1500).replace(ANSI, '').replace(/\s+/g, '');
|
|
2986
3046
|
const a0 = this.store.get(id);
|
|
2987
3047
|
rt.attention = (a0 ? engineOf(a0).attention : []).find(([re]) => re.test(screen))?.[1] ?? null;
|
|
3048
|
+
if (a0 && rt.attention === PERMISSION) this.autoAllow(a0, rt);
|
|
2988
3049
|
const a = this.store.get(id);
|
|
2989
3050
|
if (a) this.changed(a);
|
|
2990
3051
|
// A turn just ended: remember the code and conversation at this point.
|
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
|
|
194
|
-
await
|
|
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(() => {});
|