ac-framework 1.9.6 → 1.9.8
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/README.md +27 -1
- package/package.json +1 -1
- package/src/agents/collab-summary.js +120 -0
- package/src/agents/config-store.js +2 -1
- package/src/agents/constants.js +3 -0
- package/src/agents/opencode-client.js +166 -12
- package/src/agents/orchestrator.js +199 -6
- package/src/agents/role-prompts.js +34 -1
- package/src/agents/run-state.js +113 -0
- package/src/agents/runtime.js +4 -2
- package/src/agents/state-store.js +69 -1
- package/src/commands/agents.js +408 -5
- package/src/mcp/collab-server.js +307 -2
- package/src/mcp/test-harness.mjs +410 -0
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
|
|
|
141
141
|
| Command | Description |
|
|
142
142
|
|---|---|
|
|
143
143
|
| `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
|
|
144
|
+
| `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
|
|
144
145
|
| `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
|
|
145
146
|
| `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
|
|
146
147
|
| `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
|
|
@@ -149,21 +150,46 @@ Each role runs in turn against a shared, accumulating context so outputs from on
|
|
|
149
150
|
| `acfm agents attach` | Attach directly to the SynapseGrid tmux session |
|
|
150
151
|
| `acfm agents live` | Attach to full live tmux view (all agents) |
|
|
151
152
|
| `acfm agents logs` | Show recent worker logs (all roles or one role) |
|
|
153
|
+
| `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
|
|
154
|
+
| `acfm agents summary` | Show generated collaboration meeting summary |
|
|
152
155
|
| `acfm agents export --format md --out file.md` | Export transcript in Markdown or JSON |
|
|
153
156
|
| `acfm agents send "..."` | Send a new user message into the active session |
|
|
154
157
|
| `acfm agents status` | Show current collaborative session state |
|
|
158
|
+
| `acfm agents model list` | List available models grouped by provider |
|
|
159
|
+
| `acfm agents model choose` | Interactively pick provider/model and save target role |
|
|
155
160
|
| `acfm agents model get` | Show default model config (global and per-role) |
|
|
156
161
|
| `acfm agents model set --role coder provider/model` | Persist a default model for one role |
|
|
157
162
|
| `acfm agents model clear --role all` | Clear persisted model defaults |
|
|
158
163
|
| `acfm agents stop` | Stop the active collaborative session |
|
|
159
164
|
|
|
165
|
+
### MCP collaborative run mode (recommended)
|
|
166
|
+
|
|
167
|
+
When driving SynapseGrid from another agent via MCP, prefer asynchronous run tools over role-by-role stepping:
|
|
168
|
+
|
|
169
|
+
- `collab_start_session` to initialize session and optional tmux workers
|
|
170
|
+
- `collab_invoke_team` to launch full 4-role collaboration run
|
|
171
|
+
- `collab_wait_run` to wait for completion/failure with bounded timeout
|
|
172
|
+
- `collab_get_result` to fetch final consolidated output and run diagnostics
|
|
173
|
+
- `collab_cancel_run` to cancel a running collaboration safely
|
|
174
|
+
|
|
175
|
+
`collab_step` remains available for manual/debug control, but is less robust for long tasks.
|
|
176
|
+
|
|
160
177
|
### SynapseGrid troubleshooting
|
|
161
178
|
|
|
162
179
|
- If transcript entries show `Agent failed: spawn opencode ENOENT`, run `acfm agents setup` to install dependencies and then retry.
|
|
163
180
|
- Attach to worker panes with `acfm agents live` (or `acfm agents attach`) to see real-time role discussion.
|
|
164
181
|
- Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
|
|
182
|
+
- Inspect collaborative discussion with `acfm agents transcript` and `acfm agents summary`.
|
|
165
183
|
- MCP starts can now create tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
|
|
166
|
-
- Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model set`.
|
|
184
|
+
- Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model choose` / `acfm agents model set`.
|
|
185
|
+
- Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
|
|
186
|
+
- Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
|
|
187
|
+
|
|
188
|
+
Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
|
|
189
|
+
- `transcript.jsonl`: full chronological message stream
|
|
190
|
+
- `turns/*.json`: one file per round/role turn with captured output metadata
|
|
191
|
+
- `meeting-log.md`: incremental meeting notes generated per turn
|
|
192
|
+
- `meeting-summary.md`: final consolidated summary (roles, decisions, open issues, risks, action items)
|
|
167
193
|
|
|
168
194
|
### Spec Workflow
|
|
169
195
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export function summarizeText(text, maxLen = 700) {
|
|
2
|
+
if (typeof text !== 'string') return '';
|
|
3
|
+
const normalized = text.replace(/\r\n/g, '\n').trim();
|
|
4
|
+
if (!normalized) return '';
|
|
5
|
+
if (normalized.length <= maxLen) return normalized;
|
|
6
|
+
return `${normalized.slice(0, Math.max(0, maxLen - 3)).trimEnd()}...`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractBulletLines(text, limit = 5) {
|
|
10
|
+
if (typeof text !== 'string') return [];
|
|
11
|
+
const bullets = text
|
|
12
|
+
.split('\n')
|
|
13
|
+
.map((line) => line.trim())
|
|
14
|
+
.filter((line) => /^[-*]\s+/.test(line) || /^\d+\)\s+/.test(line));
|
|
15
|
+
return bullets.slice(0, Math.max(0, limit));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createTurnRecord({ round, role, model, content, events }) {
|
|
19
|
+
const snippet = summarizeText(content, 1000);
|
|
20
|
+
const keyPoints = extractBulletLines(content, 6);
|
|
21
|
+
return {
|
|
22
|
+
round,
|
|
23
|
+
role,
|
|
24
|
+
model: model || null,
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
snippet,
|
|
27
|
+
keyPoints,
|
|
28
|
+
eventCount: Array.isArray(events) ? events.length : 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateSharedContext(prev, turn) {
|
|
33
|
+
const base = prev && typeof prev === 'object'
|
|
34
|
+
? prev
|
|
35
|
+
: { decisions: [], openIssues: [], risks: [], actionItems: [], notes: [] };
|
|
36
|
+
|
|
37
|
+
const next = {
|
|
38
|
+
decisions: [...(base.decisions || [])],
|
|
39
|
+
openIssues: [...(base.openIssues || [])],
|
|
40
|
+
risks: [...(base.risks || [])],
|
|
41
|
+
actionItems: [...(base.actionItems || [])],
|
|
42
|
+
notes: [...(base.notes || [])],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const summary = summarizeText(turn?.snippet || turn?.content || '', 300);
|
|
46
|
+
if (summary) {
|
|
47
|
+
next.notes.push(`[r${turn.round}] ${turn.role}: ${summary}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines = String(turn?.snippet || turn?.content || '')
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map((line) => line.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const lower = line.toLowerCase();
|
|
57
|
+
if (lower.includes('risk') || lower.includes('failure') || lower.includes('blind spot')) {
|
|
58
|
+
next.risks.push(`[r${turn.round}] ${turn.role}: ${line}`);
|
|
59
|
+
}
|
|
60
|
+
if (lower.includes('open issue') || lower.includes('blocker') || lower.includes('unresolved')) {
|
|
61
|
+
next.openIssues.push(`[r${turn.round}] ${turn.role}: ${line}`);
|
|
62
|
+
}
|
|
63
|
+
if (lower.includes('action') || lower.includes('next step') || lower.includes('implement')) {
|
|
64
|
+
next.actionItems.push(`[r${turn.round}] ${turn.role}: ${line}`);
|
|
65
|
+
}
|
|
66
|
+
if (lower.includes('decision') || lower.includes('agreed') || lower.includes('approve')) {
|
|
67
|
+
next.decisions.push(`[r${turn.round}] ${turn.role}: ${line}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dedupe = (arr) => [...new Set(arr)].slice(-30);
|
|
72
|
+
return {
|
|
73
|
+
decisions: dedupe(next.decisions),
|
|
74
|
+
openIssues: dedupe(next.openIssues),
|
|
75
|
+
risks: dedupe(next.risks),
|
|
76
|
+
actionItems: dedupe(next.actionItems),
|
|
77
|
+
notes: dedupe(next.notes),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildMeetingSummary(messages = [], run = null, sharedContext = null) {
|
|
82
|
+
const byRole = new Map();
|
|
83
|
+
for (const msg of messages) {
|
|
84
|
+
if (!msg?.from || msg.from === 'user') continue;
|
|
85
|
+
if (!byRole.has(msg.from)) byRole.set(msg.from, []);
|
|
86
|
+
byRole.get(msg.from).push(msg.content || '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const roles = ['planner', 'critic', 'coder', 'reviewer'];
|
|
90
|
+
const lines = [];
|
|
91
|
+
lines.push('# SynapseGrid Meeting Summary');
|
|
92
|
+
if (run?.runId) lines.push(`Run: ${run.runId}`);
|
|
93
|
+
if (run?.status) lines.push(`Status: ${run.status}`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push('## Per-role recap');
|
|
96
|
+
|
|
97
|
+
for (const role of roles) {
|
|
98
|
+
const items = byRole.get(role) || [];
|
|
99
|
+
const last = items.length > 0 ? items[items.length - 1] : '';
|
|
100
|
+
lines.push(`- ${role}: ${summarizeText(last, 500) || '(no contribution captured)'}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ctx = sharedContext && typeof sharedContext === 'object' ? sharedContext : null;
|
|
104
|
+
if (ctx) {
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push('## Decisions');
|
|
107
|
+
for (const entry of (ctx.decisions || []).slice(-8)) lines.push(`- ${entry}`);
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push('## Open issues');
|
|
110
|
+
for (const entry of (ctx.openIssues || []).slice(-8)) lines.push(`- ${entry}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('## Risks');
|
|
113
|
+
for (const entry of (ctx.risks || []).slice(-8)) lines.push(`- ${entry}`);
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('## Action items');
|
|
116
|
+
for (const entry of (ctx.actionItems || []).slice(-8)) lines.push(`- ${entry}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return lines.join('\n').trim() + '\n';
|
|
120
|
+
}
|
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { DEFAULT_SYNAPSE_MODEL } from './constants.js';
|
|
5
6
|
import { sanitizeRoleModels, normalizeModelId } from './model-selection.js';
|
|
6
7
|
|
|
7
8
|
const ACFM_DIR = join(homedir(), '.acfm');
|
|
@@ -11,7 +12,7 @@ function normalizeConfig(raw) {
|
|
|
11
12
|
const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
|
|
12
13
|
return {
|
|
13
14
|
agents: {
|
|
14
|
-
defaultModel: normalizeModelId(agents.defaultModel) ||
|
|
15
|
+
defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
|
|
15
16
|
defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
|
|
16
17
|
},
|
|
17
18
|
};
|
package/src/agents/constants.js
CHANGED
|
@@ -4,5 +4,8 @@ import { join } from 'node:path';
|
|
|
4
4
|
export const COLLAB_SYSTEM_NAME = 'SynapseGrid';
|
|
5
5
|
export const COLLAB_ROLES = ['planner', 'critic', 'coder', 'reviewer'];
|
|
6
6
|
export const DEFAULT_MAX_ROUNDS = 3;
|
|
7
|
+
export const DEFAULT_SYNAPSE_MODEL = 'opencode/mimo-v2-pro-free';
|
|
8
|
+
export const DEFAULT_ROLE_TIMEOUT_MS = 180000;
|
|
9
|
+
export const DEFAULT_ROLE_RETRIES = 1;
|
|
7
10
|
export const SESSION_ROOT_DIR = join(homedir(), '.acfm', 'synapsegrid');
|
|
8
11
|
export const CURRENT_SESSION_FILE = join(SESSION_ROOT_DIR, 'current-session.json');
|
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
|
|
4
|
-
const execFileAsync = promisify(execFile);
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
5
2
|
|
|
6
3
|
function parseOpenCodeRunOutput(stdout) {
|
|
4
|
+
const ndjsonLines = stdout
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map((line) => line.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
|
|
9
|
+
if (ndjsonLines.length > 0) {
|
|
10
|
+
const textChunks = [];
|
|
11
|
+
for (const line of ndjsonLines) {
|
|
12
|
+
try {
|
|
13
|
+
const event = JSON.parse(line);
|
|
14
|
+
const text = event?.part?.text;
|
|
15
|
+
if (typeof text === 'string' && text.trim()) {
|
|
16
|
+
textChunks.push(text.trim());
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore malformed lines and continue
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (textChunks.length > 0) {
|
|
23
|
+
return textChunks.join('\n\n');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
try {
|
|
8
28
|
const parsed = JSON.parse(stdout);
|
|
9
29
|
if (Array.isArray(parsed)) {
|
|
@@ -22,7 +42,24 @@ function parseOpenCodeRunOutput(stdout) {
|
|
|
22
42
|
return stdout.trim();
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
|
|
45
|
+
function parseOpenCodeEvents(stdout) {
|
|
46
|
+
const lines = stdout
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
|
|
51
|
+
const events = [];
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
try {
|
|
54
|
+
events.push(JSON.parse(line));
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore malformed event lines
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return events;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runOpenCodePromptDetailed({ prompt, cwd, model, agent, timeoutMs = 180000, binaryPath }) {
|
|
26
63
|
const binary = binaryPath || process.env.ACFM_OPENCODE_BIN || 'opencode';
|
|
27
64
|
const args = ['run', '--format', 'json'];
|
|
28
65
|
if (model) {
|
|
@@ -33,15 +70,132 @@ export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs =
|
|
|
33
70
|
}
|
|
34
71
|
args.push('--', prompt);
|
|
35
72
|
|
|
36
|
-
const { stdout, stderr } = await
|
|
73
|
+
const { stdout, stderr } = await new Promise((resolvePromise, rejectPromise) => {
|
|
74
|
+
const child = spawn(binary, args, {
|
|
75
|
+
cwd,
|
|
76
|
+
env: process.env,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let out = '';
|
|
81
|
+
let err = '';
|
|
82
|
+
let timedOut = false;
|
|
83
|
+
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
child.kill('SIGTERM');
|
|
87
|
+
setTimeout(() => child.kill('SIGKILL'), 1500).unref();
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
|
|
90
|
+
child.stdout.on('data', (chunk) => {
|
|
91
|
+
out += chunk.toString();
|
|
92
|
+
});
|
|
93
|
+
child.stderr.on('data', (chunk) => {
|
|
94
|
+
err += chunk.toString();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
child.on('error', (error) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
rejectPromise(error);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.on('close', (code, signal) => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
if (timedOut) {
|
|
105
|
+
rejectPromise(new Error(`opencode timed out after ${timeoutMs}ms`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (code !== 0) {
|
|
109
|
+
const details = [err.trim(), out.trim()].filter(Boolean).join(' | ');
|
|
110
|
+
rejectPromise(new Error(details || `opencode exited with code ${code}${signal ? ` (${signal})` : ''}`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolvePromise({ stdout: out, stderr: err });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const text = parseOpenCodeRunOutput(stdout);
|
|
118
|
+
const events = parseOpenCodeEvents(stdout);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
text: text || (stderr?.trim() || ''),
|
|
122
|
+
stdout,
|
|
123
|
+
stderr,
|
|
124
|
+
events,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs = 180000, binaryPath }) {
|
|
129
|
+
const detailed = await runOpenCodePromptDetailed({
|
|
130
|
+
prompt,
|
|
37
131
|
cwd,
|
|
38
|
-
|
|
39
|
-
|
|
132
|
+
model,
|
|
133
|
+
agent,
|
|
134
|
+
timeoutMs,
|
|
135
|
+
binaryPath,
|
|
40
136
|
});
|
|
137
|
+
return detailed.text;
|
|
138
|
+
}
|
|
41
139
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
140
|
+
export async function listOpenCodeModels({ provider = null, binaryPath, refresh = false, timeoutMs = 45000 }) {
|
|
141
|
+
const binary = binaryPath || process.env.ACFM_OPENCODE_BIN || 'opencode';
|
|
142
|
+
const args = ['models'];
|
|
143
|
+
if (provider) {
|
|
144
|
+
args.push(provider);
|
|
45
145
|
}
|
|
46
|
-
|
|
146
|
+
if (refresh) {
|
|
147
|
+
args.push('--refresh');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stdout = await new Promise((resolvePromise, rejectPromise) => {
|
|
151
|
+
const child = spawn(binary, args, {
|
|
152
|
+
cwd: process.cwd(),
|
|
153
|
+
env: process.env,
|
|
154
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let out = '';
|
|
158
|
+
let err = '';
|
|
159
|
+
let timedOut = false;
|
|
160
|
+
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
timedOut = true;
|
|
163
|
+
child.kill('SIGTERM');
|
|
164
|
+
setTimeout(() => child.kill('SIGKILL'), 1500).unref();
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
|
|
167
|
+
child.stdout.on('data', (chunk) => {
|
|
168
|
+
out += chunk.toString();
|
|
169
|
+
});
|
|
170
|
+
child.stderr.on('data', (chunk) => {
|
|
171
|
+
err += chunk.toString();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
child.on('error', (error) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
rejectPromise(error);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
child.on('close', (code, signal) => {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
if (timedOut) {
|
|
182
|
+
rejectPromise(new Error(`opencode models timed out after ${timeoutMs}ms`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (code !== 0) {
|
|
186
|
+
const details = [err.trim(), out.trim()].filter(Boolean).join(' | ');
|
|
187
|
+
rejectPromise(new Error(details || `opencode models exited with code ${code}${signal ? ` (${signal})` : ''}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
resolvePromise(out);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const models = stdout
|
|
195
|
+
.split('\n')
|
|
196
|
+
.map((line) => line.trim())
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.filter((line) => line.includes('/'));
|
|
199
|
+
|
|
200
|
+
return [...new Set(models)].sort((a, b) => a.localeCompare(b));
|
|
47
201
|
}
|