@pruddiman/hem 0.0.1-beta-5671db0
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/LICENSE +21 -0
- package/dist/agents/arbiter-agent.d.ts +72 -0
- package/dist/agents/arbiter-agent.js +149 -0
- package/dist/agents/architecture-agent.d.ts +148 -0
- package/dist/agents/architecture-agent.js +459 -0
- package/dist/agents/base-agent.d.ts +44 -0
- package/dist/agents/base-agent.js +57 -0
- package/dist/agents/crossref-agent.d.ts +140 -0
- package/dist/agents/crossref-agent.js +560 -0
- package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
- package/dist/agents/crossref-arbiter-agent.js +147 -0
- package/dist/agents/documentation-agent.d.ts +55 -0
- package/dist/agents/documentation-agent.js +159 -0
- package/dist/agents/exploration-agent.d.ts +58 -0
- package/dist/agents/exploration-agent.js +102 -0
- package/dist/agents/grouping-agent.d.ts +167 -0
- package/dist/agents/grouping-agent.js +557 -0
- package/dist/agents/index-agent.d.ts +86 -0
- package/dist/agents/index-agent.js +360 -0
- package/dist/agents/organization-agent.d.ts +144 -0
- package/dist/agents/organization-agent.js +607 -0
- package/dist/auth.d.ts +372 -0
- package/dist/auth.js +1072 -0
- package/dist/broadcast-mcp.d.ts +21 -0
- package/dist/broadcast-mcp.js +59 -0
- package/dist/changelog.d.ts +85 -0
- package/dist/changelog.js +223 -0
- package/dist/decision-queue.d.ts +173 -0
- package/dist/decision-queue.js +265 -0
- package/dist/diff-scope.d.ts +24 -0
- package/dist/diff-scope.js +28 -0
- package/dist/discovery.d.ts +54 -0
- package/dist/discovery.js +405 -0
- package/dist/grouping.d.ts +37 -0
- package/dist/grouping.js +343 -0
- package/dist/helpers/format.d.ts +5 -0
- package/dist/helpers/format.js +13 -0
- package/dist/helpers/index.d.ts +11 -0
- package/dist/helpers/index.js +11 -0
- package/dist/helpers/parsing.d.ts +52 -0
- package/dist/helpers/parsing.js +128 -0
- package/dist/helpers/paths.d.ts +41 -0
- package/dist/helpers/paths.js +67 -0
- package/dist/helpers/strings.d.ts +45 -0
- package/dist/helpers/strings.js +97 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +1087 -0
- package/dist/merge-utils.d.ts +22 -0
- package/dist/merge-utils.js +34 -0
- package/dist/orchestrator.d.ts +194 -0
- package/dist/orchestrator.js +1169 -0
- package/dist/output.d.ts +106 -0
- package/dist/output.js +243 -0
- package/dist/progress.d.ts +228 -0
- package/dist/progress.js +644 -0
- package/dist/providers/copilot.d.ts +247 -0
- package/dist/providers/copilot.js +598 -0
- package/dist/providers/index.d.ts +15 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/opencode.d.ts +156 -0
- package/dist/providers/opencode.js +416 -0
- package/dist/providers/types.d.ts +156 -0
- package/dist/providers/types.js +16 -0
- package/dist/resources.d.ts +76 -0
- package/dist/resources.js +151 -0
- package/dist/search-index.d.ts +71 -0
- package/dist/search-index.js +187 -0
- package/dist/search-mcp.d.ts +25 -0
- package/dist/search-mcp.js +100 -0
- package/dist/server-utils.d.ts +56 -0
- package/dist/server-utils.js +135 -0
- package/dist/session.d.ts +227 -0
- package/dist/session.js +370 -0
- package/dist/types.d.ts +272 -0
- package/dist/types.js +5 -0
- package/dist/worktree.d.ts +82 -0
- package/dist/worktree.js +187 -0
- package/package.json +45 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-assisted cross-reference agent for Hem.
|
|
3
|
+
*
|
|
4
|
+
* Post-processing agent that runs AFTER the organization agent.
|
|
5
|
+
* Reads all generated documentation files and adds inter-document links
|
|
6
|
+
* (cross-references) to improve navigation.
|
|
7
|
+
*
|
|
8
|
+
* Architecture (v2 — parallel workers with broadcast):
|
|
9
|
+
* - Reads all generated docs from disk.
|
|
10
|
+
* - Adds cross-reference links between related pages.
|
|
11
|
+
* - For large file sets (>8 files), splits work across N parallel workers.
|
|
12
|
+
* - Workers communicate via an MCP broadcast tool + prompt injection.
|
|
13
|
+
* - The orchestrator intercepts broadcast tool calls via SSE and relays
|
|
14
|
+
* messages to all peer workers + their active subagents.
|
|
15
|
+
* - For small file sets (≤8 files), falls back to the single-agent path.
|
|
16
|
+
* - The pipeline discovers the final file set by scanning disk afterward.
|
|
17
|
+
*/
|
|
18
|
+
import { AuthExpiredError } from "../auth.js";
|
|
19
|
+
import { BaseAgent } from "./base-agent.js";
|
|
20
|
+
import { computeMaxConcurrency } from "../resources.js";
|
|
21
|
+
import { CrossRefArbiterAgent } from "./crossref-arbiter-agent.js";
|
|
22
|
+
import { BROADCAST_TOOL_NAME } from "./organization-agent.js";
|
|
23
|
+
// ── Constants ───────────────────────────────────────────────────────────
|
|
24
|
+
/** File count threshold: use parallel workers above this, single agent below. */
|
|
25
|
+
export const XREF_PARALLEL_THRESHOLD = 8;
|
|
26
|
+
/**
|
|
27
|
+
* Hard ceiling on parallel cross-ref workers. The actual worker count is
|
|
28
|
+
* `min(XREF_MAX_WORKERS, computeMaxConcurrency(), fileCount)`.
|
|
29
|
+
* The arbiter is excluded from this cap (it is lightweight).
|
|
30
|
+
*/
|
|
31
|
+
export const XREF_MAX_WORKERS = 4;
|
|
32
|
+
// ── Agent ───────────────────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* An agent that reads all generated documentation and adds inter-document
|
|
35
|
+
* cross-reference links for improved navigation.
|
|
36
|
+
*
|
|
37
|
+
* Edits files directly via the edit tool. The pipeline discovers the
|
|
38
|
+
* final file set by scanning disk afterward.
|
|
39
|
+
*/
|
|
40
|
+
export class CrossRefAgent extends BaseAgent {
|
|
41
|
+
constructor(provider) {
|
|
42
|
+
super(provider);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run the cross-reference pass over all generated documentation.
|
|
46
|
+
* Automatically selects single-agent or parallel mode based on file count.
|
|
47
|
+
*
|
|
48
|
+
* @param params - Cross-reference parameters including file paths.
|
|
49
|
+
* @param verbose - Optional logging callback (writes to stderr).
|
|
50
|
+
* @throws If session creation or prompting fails.
|
|
51
|
+
*/
|
|
52
|
+
async run(params, verbose) {
|
|
53
|
+
if (params.allDocFiles.length > XREF_PARALLEL_THRESHOLD) {
|
|
54
|
+
return this.runParallel(params, verbose);
|
|
55
|
+
}
|
|
56
|
+
return this.runSingle(params, verbose);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Single-agent cross-reference pass (original behavior).
|
|
60
|
+
* Used when file count is ≤ XREF_PARALLEL_THRESHOLD.
|
|
61
|
+
*/
|
|
62
|
+
async runSingle(params, verbose) {
|
|
63
|
+
const tag = "xref-agent";
|
|
64
|
+
// 1. Build prompt
|
|
65
|
+
const prompt = CrossRefAgent.buildPrompt(params);
|
|
66
|
+
if (verbose) {
|
|
67
|
+
verbose(`[${tag}] Prompt: ${prompt.length.toLocaleString()} chars`);
|
|
68
|
+
}
|
|
69
|
+
// 2. Create a new session
|
|
70
|
+
const sessionId = await this.createSession("Hem: cross-references");
|
|
71
|
+
if (verbose) {
|
|
72
|
+
verbose(`[${tag}] Session created: ${sessionId}`);
|
|
73
|
+
}
|
|
74
|
+
// 3. Send prompt — use hem-xref agent
|
|
75
|
+
await this.provider.prompt(sessionId, prompt, { agent: "hem-xref" });
|
|
76
|
+
if (verbose) {
|
|
77
|
+
verbose(`[${tag}] Agent completed`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parallel cross-reference pass using multiple workers with an arbiter.
|
|
82
|
+
*
|
|
83
|
+
* 1. Computes worker count from resource limits (arbiter excluded).
|
|
84
|
+
* 2. Assigns files to workers via round-robin.
|
|
85
|
+
* 3. Subscribes to SSE events for broadcast interception.
|
|
86
|
+
* 4. Creates an arbiter session (long-lived coordinator).
|
|
87
|
+
* 5. Creates N worker sessions in parallel.
|
|
88
|
+
* 6. Relays broadcasts with targeted routing:
|
|
89
|
+
* - Worker → arbiter only.
|
|
90
|
+
* - Arbiter → @tagged worker(s) only (or all if @all-workers / no tag).
|
|
91
|
+
* - Completed sessions are excluded from relay.
|
|
92
|
+
* 7. Kills worker sessions immediately on completion (abort + delete).
|
|
93
|
+
* 8. Intercepts RECALL: broadcasts to respawn a worker for fixes.
|
|
94
|
+
* 9. Sends a final prompt to the arbiter so it can wrap up.
|
|
95
|
+
* 10. Kills the arbiter session.
|
|
96
|
+
*/
|
|
97
|
+
async runParallel(params, verbose) {
|
|
98
|
+
const tag = "xref-parallel";
|
|
99
|
+
const { projectName, destinationPath, allDocFiles } = params;
|
|
100
|
+
// 1. Compute effective worker count from resource limits (arbiter excluded)
|
|
101
|
+
const resourceCap = computeMaxConcurrency();
|
|
102
|
+
const effectiveMaxWorkers = Math.min(XREF_MAX_WORKERS, resourceCap);
|
|
103
|
+
const assignments = assignXrefFilesToWorkers(allDocFiles, effectiveMaxWorkers);
|
|
104
|
+
if (verbose) {
|
|
105
|
+
verbose(`[${tag}] Resource cap: ${resourceCap} sessions (arbiter excluded)`);
|
|
106
|
+
verbose(`[${tag}] Splitting ${allDocFiles.length} files across ${assignments.length} workers + 1 arbiter`);
|
|
107
|
+
for (const a of assignments) {
|
|
108
|
+
verbose(`[${tag}] ${a.label}: ${a.files.length} files`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// 2. Session tracking
|
|
112
|
+
// allSessions: sessionId → label (live sessions only — removed on kill)
|
|
113
|
+
// workerSessionIds: set of worker session IDs (for routing logic)
|
|
114
|
+
// completedSessions: workers that finished (excluded from relay)
|
|
115
|
+
// childSessions: childSessionId → parentSessionId (subagent tracking)
|
|
116
|
+
const allSessions = new Map();
|
|
117
|
+
const workerSessionIds = new Set();
|
|
118
|
+
const completedSessions = new Set();
|
|
119
|
+
const childSessions = new Map();
|
|
120
|
+
// Store arbiter session ID at this scope so the relay can identify it
|
|
121
|
+
let arbiterSessionId = "";
|
|
122
|
+
// Keep a reference to the original assignments for RECALL
|
|
123
|
+
const assignmentsByLabel = new Map();
|
|
124
|
+
for (const a of assignments) {
|
|
125
|
+
assignmentsByLabel.set(a.label, a);
|
|
126
|
+
}
|
|
127
|
+
// Pending recall promises collected asynchronously
|
|
128
|
+
const pendingRecalls = [];
|
|
129
|
+
// 3. Subscribe to SSE events for broadcast interception
|
|
130
|
+
// (NO DecisionQueue — cross-ref workers only add links, no MERGE/DELETE)
|
|
131
|
+
let sseStream = null;
|
|
132
|
+
let sseLoopDone = false;
|
|
133
|
+
const startSseRelay = async () => {
|
|
134
|
+
try {
|
|
135
|
+
const subscribeResult = await this.provider.event.subscribe();
|
|
136
|
+
sseStream = subscribeResult.stream;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (verbose) {
|
|
140
|
+
verbose(`[${tag}] ⚠ Failed to subscribe to SSE events: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (verbose) {
|
|
145
|
+
verbose(`[${tag}] SSE relay started`);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
for await (const rawEvent of sseStream) {
|
|
149
|
+
if (sseLoopDone)
|
|
150
|
+
break;
|
|
151
|
+
const event = rawEvent;
|
|
152
|
+
if (!event.type || !event.properties)
|
|
153
|
+
continue;
|
|
154
|
+
if (event.type === "message.part.updated") {
|
|
155
|
+
const part = event.properties.part;
|
|
156
|
+
if (!part || part.type !== "tool")
|
|
157
|
+
continue;
|
|
158
|
+
// ── Detect broadcast tool calls ──────────────────────
|
|
159
|
+
if (part.tool === BROADCAST_TOOL_NAME &&
|
|
160
|
+
part.state?.status === "running" &&
|
|
161
|
+
part.sessionID &&
|
|
162
|
+
part.state.input) {
|
|
163
|
+
const message = part.state.input.message;
|
|
164
|
+
const senderSessionId = part.sessionID;
|
|
165
|
+
const senderLabel = allSessions.get(senderSessionId);
|
|
166
|
+
if (message && senderLabel) {
|
|
167
|
+
if (verbose) {
|
|
168
|
+
verbose(`[${tag}] 📢 Broadcast from ${senderLabel}: ${message}`);
|
|
169
|
+
}
|
|
170
|
+
// ── RECALL interception ──────────────────────────
|
|
171
|
+
// If the arbiter broadcasts "RECALL: @xref-worker-N <instructions>",
|
|
172
|
+
// intercept it and spawn a new focused worker session.
|
|
173
|
+
const recallMatch = message.match(/^RECALL:\s*@(xref-worker-\d+)\s+([\s\S]+)/i);
|
|
174
|
+
if (recallMatch && !workerSessionIds.has(senderSessionId)) {
|
|
175
|
+
const targetLabel = recallMatch[1];
|
|
176
|
+
const instructions = recallMatch[2];
|
|
177
|
+
const originalAssignment = assignmentsByLabel.get(targetLabel);
|
|
178
|
+
if (originalAssignment) {
|
|
179
|
+
if (verbose) {
|
|
180
|
+
verbose(`[${tag}] 🔄 Recalling ${targetLabel} for fixes`);
|
|
181
|
+
}
|
|
182
|
+
const recallPromise = this.runRecalledWorker(projectName, destinationPath, originalAssignment, allDocFiles, assignments.length, instructions, verbose).catch((err) => {
|
|
183
|
+
if (verbose) {
|
|
184
|
+
verbose(`[${tag}] ✗ Recall of ${targetLabel} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
pendingRecalls.push(recallPromise);
|
|
188
|
+
}
|
|
189
|
+
else if (verbose) {
|
|
190
|
+
verbose(`[${tag}] ⚠ RECALL target ${targetLabel} not found in assignments`);
|
|
191
|
+
}
|
|
192
|
+
// Don't relay RECALL messages — the orchestrator handles them
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
// ── Targeted routing ─────────────────────────────
|
|
196
|
+
const relayText = `Message from ${senderLabel}: ${message}`;
|
|
197
|
+
const relayTargets = [];
|
|
198
|
+
const senderIsWorker = workerSessionIds.has(senderSessionId);
|
|
199
|
+
if (senderIsWorker) {
|
|
200
|
+
// Worker → always relay to arbiter only
|
|
201
|
+
if (arbiterSessionId && !completedSessions.has(arbiterSessionId)) {
|
|
202
|
+
relayTargets.push({ id: arbiterSessionId, label: "xref-arbiter" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Arbiter → check for @tags in message
|
|
207
|
+
const tagPattern = /@(xref-worker-\d+)/g;
|
|
208
|
+
const tags = [...message.matchAll(tagPattern)].map(m => m[1]);
|
|
209
|
+
const isAllWorkers = /@all-workers/i.test(message);
|
|
210
|
+
if (tags.length > 0 && !isAllWorkers) {
|
|
211
|
+
// Route to specifically tagged workers only
|
|
212
|
+
for (const [sessionId, label] of allSessions) {
|
|
213
|
+
if (sessionId === senderSessionId)
|
|
214
|
+
continue;
|
|
215
|
+
if (completedSessions.has(sessionId))
|
|
216
|
+
continue;
|
|
217
|
+
if (tags.some(t => label === t)) {
|
|
218
|
+
relayTargets.push({ id: sessionId, label });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// @all-workers or no tags → broadcast to all live workers
|
|
224
|
+
for (const [sessionId, label] of allSessions) {
|
|
225
|
+
if (sessionId === senderSessionId)
|
|
226
|
+
continue;
|
|
227
|
+
if (completedSessions.has(sessionId))
|
|
228
|
+
continue;
|
|
229
|
+
if (workerSessionIds.has(sessionId)) {
|
|
230
|
+
relayTargets.push({ id: sessionId, label });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Also include active subagents of each relay target
|
|
236
|
+
const subagentTargets = [];
|
|
237
|
+
for (const target of relayTargets) {
|
|
238
|
+
for (const [childId, parentId] of childSessions) {
|
|
239
|
+
if (parentId === target.id && !completedSessions.has(childId)) {
|
|
240
|
+
subagentTargets.push({ id: childId, label: `${target.label}/subagent` });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
relayTargets.push(...subagentTargets);
|
|
245
|
+
// Fire relay prompts concurrently (fire-and-forget)
|
|
246
|
+
if (relayTargets.length > 0) {
|
|
247
|
+
const relayPromises = relayTargets.map(async (target) => {
|
|
248
|
+
try {
|
|
249
|
+
await this.provider.session.promptAsync({
|
|
250
|
+
path: { id: target.id },
|
|
251
|
+
body: {
|
|
252
|
+
parts: [{ type: "text", text: relayText }],
|
|
253
|
+
agent: "hem-xref",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (verbose) {
|
|
257
|
+
verbose(`[${tag}] → Relayed to ${target.label} (${target.id.slice(0, 8)}…)`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (verbose) {
|
|
262
|
+
verbose(`[${tag}] ✗ Relay to ${target.label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
// Don't await — let relays happen async while SSE loop continues
|
|
267
|
+
void Promise.allSettled(relayPromises);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ── Track child sessions via session.created events ────
|
|
273
|
+
if (event.type === "session.created") {
|
|
274
|
+
const props = event.properties;
|
|
275
|
+
if (props.session?.id && props.session.parentID) {
|
|
276
|
+
const parentId = props.session.parentID;
|
|
277
|
+
// Only track if the parent is one of our sessions (worker or arbiter)
|
|
278
|
+
if (allSessions.has(parentId)) {
|
|
279
|
+
childSessions.set(props.session.id, parentId);
|
|
280
|
+
if (verbose) {
|
|
281
|
+
const parentLabel = allSessions.get(parentId);
|
|
282
|
+
verbose(`[${tag}] · Subagent ${props.session.id.slice(0, 8)}… spawned by ${parentLabel}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Also check if parent is itself a tracked child (nested subagents)
|
|
286
|
+
else if (childSessions.has(parentId)) {
|
|
287
|
+
const rootParent = childSessions.get(parentId);
|
|
288
|
+
childSessions.set(props.session.id, rootParent);
|
|
289
|
+
if (verbose) {
|
|
290
|
+
const rootLabel = allSessions.get(rootParent);
|
|
291
|
+
verbose(`[${tag}] · Nested subagent ${props.session.id.slice(0, 8)}… (root: ${rootLabel})`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
// SSE stream can error when the server shuts down or on network issues.
|
|
300
|
+
// This is expected during cleanup — only log if we're still running.
|
|
301
|
+
if (!sseLoopDone && verbose) {
|
|
302
|
+
verbose(`[${tag}] ⚠ SSE stream error: ${err instanceof Error ? err.message : String(err)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
// Start SSE relay in background (don't await — runs concurrently)
|
|
307
|
+
const sseRelayPromise = startSseRelay();
|
|
308
|
+
try {
|
|
309
|
+
// 4. Create arbiter session (long-lived coordinator) via CrossRefArbiterAgent
|
|
310
|
+
const arbiter = new CrossRefArbiterAgent(this.provider);
|
|
311
|
+
const { sessionId: aId } = await arbiter.run({
|
|
312
|
+
projectName,
|
|
313
|
+
destinationPath,
|
|
314
|
+
allDocFiles,
|
|
315
|
+
workerAssignments: assignments,
|
|
316
|
+
}, verbose);
|
|
317
|
+
arbiterSessionId = aId;
|
|
318
|
+
allSessions.set(arbiterSessionId, "xref-arbiter");
|
|
319
|
+
// 5. Create worker sessions and send prompts concurrently
|
|
320
|
+
const workerPromises = assignments.map(async (assignment) => {
|
|
321
|
+
const prompt = CrossRefAgent.buildWorkerPrompt({
|
|
322
|
+
projectName,
|
|
323
|
+
destinationPath,
|
|
324
|
+
assignedFiles: assignment.files,
|
|
325
|
+
allDocFiles,
|
|
326
|
+
workerLabel: assignment.label,
|
|
327
|
+
totalWorkers: assignments.length,
|
|
328
|
+
});
|
|
329
|
+
if (verbose) {
|
|
330
|
+
verbose(`[${tag}] ${assignment.label}: prompt ${prompt.length.toLocaleString()} chars`);
|
|
331
|
+
}
|
|
332
|
+
// Create session
|
|
333
|
+
const sessionId = await this.createSession(`Hem: ${assignment.label}`);
|
|
334
|
+
allSessions.set(sessionId, assignment.label);
|
|
335
|
+
workerSessionIds.add(sessionId);
|
|
336
|
+
if (verbose) {
|
|
337
|
+
verbose(`[${tag}] ${assignment.label}: session ${sessionId}`);
|
|
338
|
+
}
|
|
339
|
+
// Send prompt and wait for completion
|
|
340
|
+
await this.provider.prompt(sessionId, prompt, { agent: "hem-xref" });
|
|
341
|
+
// ── Kill the worker session immediately ──────────────────
|
|
342
|
+
// Worker is done — free resources. Mark as completed first
|
|
343
|
+
// so the relay loop stops sending messages to it, then abort
|
|
344
|
+
// and delete the session.
|
|
345
|
+
completedSessions.add(sessionId);
|
|
346
|
+
allSessions.delete(sessionId);
|
|
347
|
+
await this.killSession(sessionId, assignment.label, verbose);
|
|
348
|
+
});
|
|
349
|
+
// 6. Wait for all workers to complete
|
|
350
|
+
const results = await Promise.allSettled(workerPromises);
|
|
351
|
+
// 7. Check for auth errors first
|
|
352
|
+
for (const result of results) {
|
|
353
|
+
if (result.status === "rejected" &&
|
|
354
|
+
result.reason instanceof AuthExpiredError) {
|
|
355
|
+
throw result.reason;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 8. Signal the arbiter that all workers are done
|
|
359
|
+
await arbiter.wrapUp(arbiterSessionId, verbose);
|
|
360
|
+
// 9. Wait for any pending recall sessions spawned during arbiter wrap-up
|
|
361
|
+
if (pendingRecalls.length > 0) {
|
|
362
|
+
if (verbose) {
|
|
363
|
+
verbose(`[${tag}] Waiting for ${pendingRecalls.length} pending recall(s)...`);
|
|
364
|
+
}
|
|
365
|
+
await Promise.allSettled(pendingRecalls);
|
|
366
|
+
}
|
|
367
|
+
// 10. Kill the arbiter session
|
|
368
|
+
completedSessions.add(arbiterSessionId);
|
|
369
|
+
allSessions.delete(arbiterSessionId);
|
|
370
|
+
await this.killSession(arbiterSessionId, "xref-arbiter", verbose);
|
|
371
|
+
// 11. Log any worker failures (the pipeline discovers files via disk scan)
|
|
372
|
+
for (let i = 0; i < results.length; i++) {
|
|
373
|
+
const result = results[i];
|
|
374
|
+
const label = assignments[i].label;
|
|
375
|
+
if (result.status === "rejected") {
|
|
376
|
+
const msg = result.reason instanceof Error
|
|
377
|
+
? result.reason.message
|
|
378
|
+
: String(result.reason);
|
|
379
|
+
if (verbose) {
|
|
380
|
+
verbose(`[${tag}] ✗ ${label} failed: ${msg}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (verbose) {
|
|
384
|
+
verbose(`[${tag}] ✓ ${label} completed`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
// 12. Stop the SSE relay loop
|
|
390
|
+
sseLoopDone = true;
|
|
391
|
+
// The SSE stream will naturally close when the server shuts down,
|
|
392
|
+
// but we set the flag so any in-flight iterations exit cleanly.
|
|
393
|
+
await Promise.race([
|
|
394
|
+
sseRelayPromise,
|
|
395
|
+
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
396
|
+
]);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Kill a session: abort any running work, then delete the session.
|
|
401
|
+
* Best-effort — failures are logged but not thrown.
|
|
402
|
+
*/
|
|
403
|
+
async killSession(sessionId, label, verbose) {
|
|
404
|
+
try {
|
|
405
|
+
await this.provider.session.abort({ path: { id: sessionId } });
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Session may already be idle — abort failing is fine
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
await this.provider.session.delete({ path: { id: sessionId } });
|
|
412
|
+
if (verbose) {
|
|
413
|
+
verbose(`[xref-parallel] 🗑 Killed session ${label} (${sessionId.slice(0, 8)}…)`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
if (verbose) {
|
|
418
|
+
verbose(`[xref-parallel] ⚠ Failed to delete session ${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Spawn a recalled worker session to apply specific fixes.
|
|
424
|
+
*
|
|
425
|
+
* Called when the arbiter broadcasts `RECALL: @xref-worker-N <instructions>`.
|
|
426
|
+
* Creates a new session with the worker's original file assignment and a
|
|
427
|
+
* focused prompt containing the fix instructions. The session is killed
|
|
428
|
+
* immediately after completion.
|
|
429
|
+
*/
|
|
430
|
+
async runRecalledWorker(projectName, destinationPath, assignment, _allDocFiles, _totalWorkers, instructions, verbose) {
|
|
431
|
+
const tag = `recall-${assignment.label}`;
|
|
432
|
+
const prompt = [
|
|
433
|
+
`Worker **${assignment.label}** (recalled). Previously added cross-reference`,
|
|
434
|
+
`links for **${projectName}** and the arbiter found link consistency issues.`,
|
|
435
|
+
"",
|
|
436
|
+
"## Destination directory",
|
|
437
|
+
"",
|
|
438
|
+
`All documentation files are in: \`${destinationPath}\``,
|
|
439
|
+
"",
|
|
440
|
+
"## Your assigned files",
|
|
441
|
+
"",
|
|
442
|
+
...assignment.files.map((f) => `- \`${destinationPath}/${f}\``),
|
|
443
|
+
"",
|
|
444
|
+
"## Fix instructions from the arbiter",
|
|
445
|
+
"",
|
|
446
|
+
instructions,
|
|
447
|
+
"",
|
|
448
|
+
"## Rules",
|
|
449
|
+
"",
|
|
450
|
+
"- Use the edit tool to make the requested fixes directly.",
|
|
451
|
+
"- Only modify files in your assigned list unless the arbiter specifically",
|
|
452
|
+
" instructed otherwise.",
|
|
453
|
+
"- When you have completed all fixes, stop.",
|
|
454
|
+
].join("\n");
|
|
455
|
+
if (verbose) {
|
|
456
|
+
verbose(`[${tag}] Recall prompt: ${prompt.length.toLocaleString()} chars`);
|
|
457
|
+
}
|
|
458
|
+
const sessionId = await this.createSession(`Hem: ${tag}`);
|
|
459
|
+
if (verbose) {
|
|
460
|
+
verbose(`[${tag}] Session ${sessionId}`);
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
await this.provider.prompt(sessionId, prompt, { agent: "hem-xref" });
|
|
464
|
+
if (verbose) {
|
|
465
|
+
verbose(`[${tag}] Recall completed`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
// Always kill the recall session
|
|
470
|
+
await this.killSession(sessionId, tag, verbose);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Builds the single-agent cross-reference prompt (original behavior).
|
|
475
|
+
*/
|
|
476
|
+
static buildPrompt(params) {
|
|
477
|
+
const { projectName, destinationPath, allDocFiles } = params;
|
|
478
|
+
const parts = [];
|
|
479
|
+
parts.push(`Read all generated documentation files for **${projectName}** and add`, `inter-document links to improve navigation between related pages.`, "", `**Edit files directly using the edit tool.** Do NOT return Markdown content`, `in your response text. When done making changes, stop.`, "");
|
|
480
|
+
parts.push("## Destination directory", "", `All documentation files are in: \`${destinationPath}\``, "");
|
|
481
|
+
parts.push("## Documentation files", "");
|
|
482
|
+
for (const file of allDocFiles) {
|
|
483
|
+
parts.push(`- \`${destinationPath}/${file}\``);
|
|
484
|
+
}
|
|
485
|
+
parts.push("");
|
|
486
|
+
parts.push("## Your tasks", "", "Read ALL the documentation files listed above, then:", "", "1. **Add \"Related Documentation\" sections**: At the bottom of each page, add", " or update a \"## Related Documentation\" section with links to related pages.", " Use descriptive link text and brief descriptions.", "", "2. **Add inline cross-references**: Within the body text, add links to other", " documentation pages where concepts are mentioned. For example, if a feature", " page mentions the authentication system, link to the auth documentation.", "", "3. **TSG bidirectional linking**: Ensure documentation pages link to relevant", " TSG pages and vice versa.", "", "4. **Use relative paths**: All links must use relative paths between files", " (e.g., `../auth/overview.md` from a TSG page).", "", "5. **Don't add redundant links**: Only add links that provide genuine", " navigational value. Don't link every mention of every concept.", "");
|
|
487
|
+
parts.push("## When you are done", "", "After making changes using the edit tool, simply stop.", "Do NOT return a JSON manifest or any other structured output.", "The pipeline will scan the destination directory to discover the final file set.");
|
|
488
|
+
return parts.join("\n");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Builds the prompt for a parallel cross-ref worker.
|
|
492
|
+
*
|
|
493
|
+
* Each worker gets:
|
|
494
|
+
* - A scoped identity (e.g. "xref-worker-1 of 3")
|
|
495
|
+
* - Its assigned file subset
|
|
496
|
+
* - The full file list for cross-reference awareness
|
|
497
|
+
* - Instructions for using the broadcast tool
|
|
498
|
+
* - Scoped task list (only edit YOUR files)
|
|
499
|
+
*/
|
|
500
|
+
static buildWorkerPrompt(params) {
|
|
501
|
+
const { projectName, destinationPath, assignedFiles, allDocFiles, workerLabel, totalWorkers, } = params;
|
|
502
|
+
const parts = [];
|
|
503
|
+
// ── Role section ──────────────────────────────────────────────────
|
|
504
|
+
parts.push(`Worker **${workerLabel}**: read the assigned documentation files for`, `**${projectName}** and add inter-document cross-reference links to improve`, `navigation.`, "", `There are ${totalWorkers} workers running in parallel; each owns a distinct`, `set of files. Coordinate with the arbiter via the broadcast tool.`, "", `**Edit files directly using the edit tool.** Full write access is available`, `— use it. Do NOT delegate file edits to the arbiter or anyone else. Do NOT`, `return Markdown content in your response text. Make changes with the edit`, `tool, then stop when done.`, "", `**IMPORTANT: This session will be terminated when finished.** Complete all`, `edits first, then stop.`, "");
|
|
505
|
+
// ── Destination directory ─────────────────────────────────────────
|
|
506
|
+
parts.push("## Destination directory", "", `All documentation files are in: \`${destinationPath}\``, "");
|
|
507
|
+
// ── Assigned files ────────────────────────────────────────────────
|
|
508
|
+
parts.push("## Your assigned files", "", "The assigned files for cross-reference linking are:", "");
|
|
509
|
+
for (const file of assignedFiles) {
|
|
510
|
+
parts.push(`- \`${destinationPath}/${file}\``);
|
|
511
|
+
}
|
|
512
|
+
parts.push("");
|
|
513
|
+
// ── All files (for cross-reference awareness) ─────────────────────
|
|
514
|
+
if (allDocFiles.length > assignedFiles.length) {
|
|
515
|
+
parts.push("## All documentation files (for reference)", "", "Other workers are handling these files. You may read them for context", "but do NOT edit files outside your assigned list.", "");
|
|
516
|
+
const otherFiles = allDocFiles.filter((f) => !assignedFiles.includes(f));
|
|
517
|
+
for (const file of otherFiles) {
|
|
518
|
+
parts.push(`- \`${destinationPath}/${file}\``);
|
|
519
|
+
}
|
|
520
|
+
parts.push("");
|
|
521
|
+
}
|
|
522
|
+
// ── Tasks (scoped) ────────────────────────────────────────────────
|
|
523
|
+
parts.push("## Your tasks", "", "Read ALL of your assigned documentation files, then:", "", "1. **Add \"Related Documentation\" sections**: At the bottom of each of YOUR", " assigned files, add or update a \"## Related Documentation\" section with", " links to related pages. Use descriptive link text and brief descriptions.", "", "2. **Add inline cross-references**: Within the body text of YOUR files, add", " links to other documentation pages where concepts are mentioned.", "", "3. **TSG bidirectional linking**: Ensure YOUR documentation pages link to", " relevant TSG pages and vice versa (only edit YOUR files).", "", "4. **Use relative paths**: All links must use relative paths between files", " (e.g., `../auth/overview.md` from a TSG page).", "", "5. **Don't add redundant links**: Only add links that provide genuine", " navigational value. Don't link every mention of every concept.", "", "6. **Cross-worker link consistency**: If you spot a link consistency issue", " involving another worker's files (e.g., you added a link to a file", " owned by another worker but the reciprocal link is missing), broadcast", " a SUGGESTION so the arbiter can coordinate.", "");
|
|
524
|
+
// ── Coordination section ──────────────────────────────────────────
|
|
525
|
+
parts.push("## Coordination with the arbiter", "", "You can communicate using the **broadcast** tool. Your broadcasts go", "**ONLY to the arbiter** — other workers will NOT see your messages.", "The arbiter coordinates all cross-worker communication.", "", "### When to broadcast", "", "Broadcast whenever you add a link that could affect cross-worker consistency:", "", '- **Links added**:', ' `broadcast({ message: "LINK-ADDED: auth/overview.md → api/endpoints.md (added reference to API endpoints)" })`', "", '- **Suggestions for cross-worker link issues** (the arbiter will act on these):', ' `broadcast({ message: "SUGGESTION: auth/overview.md links to api/endpoints.md but api/endpoints.md does not link back" })`', "", '- **Acknowledgements** (only when you actually took action on a DECISION):', ' `broadcast({ message: "ACK: Added reciprocal link in auth/overview.md → api/endpoints.md" })`', "", "### When you receive messages", "", "Messages appear as user messages prefixed with the sender's label.", "You will only receive messages from **the arbiter** (DECISION messages).", "Decision types you may receive:", "", "- **ADD-LINK**: `DECISION: @your-label ADD-LINK <file> → <target-file>` —", " add a reciprocal cross-reference link. After adding, broadcast an ACK.", "", "- **UPDATE-LINK**: `DECISION: @your-label UPDATE-LINK <file>` —", " update a cross-reference link. After updating, broadcast an ACK.", "", "- **Free-form**: `DECISION: @your-label <instructions>` —", " execute the instructions directly. After executing, broadcast an ACK.", "", "You **MUST** execute each DECISION yourself. Use the edit tool for all", "link additions and updates. The arbiter decides how to resolve", "cross-worker link consistency issues.", "", "### Rules", "", "- Only edit files in YOUR assigned list. For cross-worker link issues,", " broadcast a SUGGESTION and let the arbiter decide.", "- Do NOT ACK messages you cannot act on. Only ACK when you actually", " edited files or updated links.", "- Do NOT broadcast progress updates, questions, or file contents.", "- Keep messages short and actionable.", "- You MUST use the edit tool to make all file changes yourself.", " Do NOT ask the arbiter to edit files for you.", "");
|
|
526
|
+
// ── When done ──────────────────────────────────────────────────────
|
|
527
|
+
parts.push("## When you are done", "", "After making ALL changes (including any changes requested by the arbiter),", "stop. The pipeline will scan the destination directory to discover the", "final file set.", "", "- Make sure all edits are complete before stopping.", "- **Your session will be terminated when you finish.**");
|
|
528
|
+
return parts.join("\n");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// ── File Assignment ─────────────────────────────────────────────────────
|
|
532
|
+
/**
|
|
533
|
+
* Assigns documentation files to cross-ref workers using round-robin distribution.
|
|
534
|
+
*
|
|
535
|
+
* Files are sorted alphabetically first so that files in the same
|
|
536
|
+
* directory tend to land on adjacent workers, preserving some locality.
|
|
537
|
+
* Then they are dealt out in order: file 0 → worker 1, file 1 → worker 2,
|
|
538
|
+
* …, wrapping around. This guarantees perfectly balanced workloads (±1 file).
|
|
539
|
+
*
|
|
540
|
+
* @param files - Relative file paths (e.g. "auth/overview.md").
|
|
541
|
+
* @param maxWorkers - Maximum number of workers to create.
|
|
542
|
+
* @returns An array of worker assignments, each with a label and file list.
|
|
543
|
+
*/
|
|
544
|
+
export function assignXrefFilesToWorkers(files, maxWorkers) {
|
|
545
|
+
if (files.length === 0)
|
|
546
|
+
return [];
|
|
547
|
+
const numWorkers = Math.min(maxWorkers, files.length);
|
|
548
|
+
if (numWorkers <= 1) {
|
|
549
|
+
return [{ label: "xref-worker-1", files: [...files] }];
|
|
550
|
+
}
|
|
551
|
+
const sorted = [...files].sort();
|
|
552
|
+
const workers = [];
|
|
553
|
+
for (let i = 0; i < numWorkers; i++) {
|
|
554
|
+
workers.push({ label: `xref-worker-${i + 1}`, files: [] });
|
|
555
|
+
}
|
|
556
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
557
|
+
workers[i % numWorkers].files.push(sorted[i]);
|
|
558
|
+
}
|
|
559
|
+
return workers;
|
|
560
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arbiter agent for Hem's parallel cross-reference pass.
|
|
3
|
+
*
|
|
4
|
+
* The cross-ref arbiter is a lightweight coordinator that runs alongside N
|
|
5
|
+
* parallel cross-ref workers. It receives all broadcast messages, evaluates
|
|
6
|
+
* SUGGESTION messages, and issues DECISION directives telling specific
|
|
7
|
+
* workers what to do for link consistency. It does NOT edit files itself.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle (two-phase, driven by CrossRefAgent.runParallel):
|
|
10
|
+
* 1. `run()` — creates the session, sends the initial prompt via
|
|
11
|
+
* promptAsync (fire-and-forget). Returns { sessionId }.
|
|
12
|
+
* 2. `wrapUp()` — called after all workers complete. Sends a final
|
|
13
|
+
* prompt so the arbiter can issue any remaining
|
|
14
|
+
* DECISION messages. Non-fatal on failure.
|
|
15
|
+
*/
|
|
16
|
+
import type { Provider } from "../providers/types.js";
|
|
17
|
+
import { BaseAgent } from "./base-agent.js";
|
|
18
|
+
import type { WorkerAssignment } from "./organization-agent.js";
|
|
19
|
+
/** Parameters for the cross-ref arbiter prompt. */
|
|
20
|
+
export interface CrossRefArbiterPromptParams {
|
|
21
|
+
/** Project name. */
|
|
22
|
+
projectName: string;
|
|
23
|
+
/** Absolute path to the destination directory. */
|
|
24
|
+
destinationPath: string;
|
|
25
|
+
/** ALL documentation files (for full awareness). */
|
|
26
|
+
allDocFiles: string[];
|
|
27
|
+
/** Worker assignments mapping each worker label to its owned files. */
|
|
28
|
+
workerAssignments: WorkerAssignment[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A coordinator agent that monitors cross-ref worker broadcasts and issues
|
|
32
|
+
* DECISION directives to resolve cross-worker link consistency issues.
|
|
33
|
+
*
|
|
34
|
+
* Does NOT edit files — directs workers to make all changes.
|
|
35
|
+
*/
|
|
36
|
+
export declare class CrossRefArbiterAgent extends BaseAgent {
|
|
37
|
+
constructor(provider: Provider);
|
|
38
|
+
/**
|
|
39
|
+
* Create the arbiter session and send the initial prompt.
|
|
40
|
+
*
|
|
41
|
+
* The initial prompt is sent via `promptAsync` (fire-and-forget) because
|
|
42
|
+
* the arbiter sits idle until it receives broadcasts from workers.
|
|
43
|
+
*
|
|
44
|
+
* @returns The session ID so the caller can register it in the SSE
|
|
45
|
+
* relay map and later call `wrapUp()`.
|
|
46
|
+
* @throws {AuthExpiredError} If session creation or the initial prompt
|
|
47
|
+
* fails due to authentication expiry.
|
|
48
|
+
*/
|
|
49
|
+
run(params: CrossRefArbiterPromptParams, verbose?: (msg: string) => void): Promise<{
|
|
50
|
+
sessionId: string;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Send the wrap-up prompt after all workers have completed.
|
|
54
|
+
*
|
|
55
|
+
* Asks the arbiter to issue any remaining DECISION messages and then
|
|
56
|
+
* output its final `{ "status": "complete" }` response.
|
|
57
|
+
*
|
|
58
|
+
* This is intentionally **non-fatal** — if the arbiter fails, workers
|
|
59
|
+
* have already made their edits and the pipeline can continue.
|
|
60
|
+
*/
|
|
61
|
+
wrapUp(sessionId: string, verbose?: (msg: string) => void): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Builds the cross-ref arbiter prompt.
|
|
64
|
+
*
|
|
65
|
+
* The arbiter is a lightweight coordinator that:
|
|
66
|
+
* - Receives all broadcasts from cross-ref workers
|
|
67
|
+
* - Evaluates SUGGESTION messages and issues DECISION directives
|
|
68
|
+
* - Directs specific workers to add/update reciprocal links
|
|
69
|
+
* - Does NOT edit files directly
|
|
70
|
+
*/
|
|
71
|
+
static buildPrompt(params: CrossRefArbiterPromptParams): string;
|
|
72
|
+
}
|