1import log from "../../seed/log.js";
2import { v4 as uuidv4 } from "uuid";
3import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
4
5// ── Service references (wired by index.js init) ──
6
7let _runChat = null;
8let _deliverCascade = null;
9let _setExtMeta = null;
10let _getExtMeta = null;
11let _mergeExtMeta = null;
12let _emitToUser = null;
13let _hooks = null;
14let _Node = null;
15let _Note = null;
16
17export function setServices(s) {
18 _runChat = s.runChat;
19 _deliverCascade = s.deliverCascade;
20 _setExtMeta = s.setExtMeta;
21 _getExtMeta = s.getExtMeta;
22 _mergeExtMeta = s.mergeExtMeta;
23 _emitToUser = s.emitToUser;
24 _hooks = s.hooks;
25 _Node = s.Node;
26 _Note = s.Note;
27}
28
29// ── Defaults ──
30
31const MAX_CONTENT_CHARS = 8000;
32const MAX_HISTORY_ENTRIES = 20;
33
34function defaultConfig() {
35 return {
36 partner: null,
37 trigger: "afterNote",
38 maxRounds: 5,
39 autoApply: false,
40 reviewPrompt: null,
41 status: "idle",
42 currentReviewId: null,
43 history: [],
44 };
45}
46
47// ── Read helpers ──
48
49export function getReviewConfig(node) {
50 const raw = typeof _getExtMeta === "function" ? _getExtMeta(node, "peer-review") : null;
51 if (!raw || typeof raw !== "object") return defaultConfig();
52 return { ...defaultConfig(), ...raw };
53}
54
55function truncate(text, max) {
56 if (!text || typeof text !== "string") return "";
57 return text.length > max ? text.slice(0, max) + "\n... (truncated)" : text;
58}
59
60// ── Status updates ──
61
62async function setStatus(node, status, extra = {}) {
63 const config = getReviewConfig(node);
64 const update = { ...config, status, ...extra };
65 await _setExtMeta(node, "peer-review", update);
66 return update;
67}
68
69async function addHistoryEntry(node, entry) {
70 const config = getReviewConfig(node);
71 const history = Array.isArray(config.history) ? [...config.history] : [];
72 history.push(entry);
73 // Cap history
74 while (history.length > MAX_HISTORY_ENTRIES) history.shift();
75 await _setExtMeta(node, "peer-review", { ...config, history });
76}
77
78async function addRoundToHistory(node, reviewId, roundData) {
79 const config = getReviewConfig(node);
80 const history = Array.isArray(config.history) ? [...config.history] : [];
81 const session = history.find((h) => h.id === reviewId);
82 if (session) {
83 session.rounds.push(roundData);
84 }
85 await _setExtMeta(node, "peer-review", { ...config, history });
86}
87
88// ── Resolve root for a node ──
89
90async function resolveRoot(nodeId) {
91 let cursor = nodeId;
92 const visited = new Set();
93 while (cursor && !visited.has(cursor)) {
94 visited.add(cursor);
95 const n = await _Node.findById(cursor).select("parent rootOwner systemRole").lean();
96 if (!n) return null;
97 if (n.rootOwner) return String(n._id);
98 if (!n.parent || n.systemRole) return null;
99 cursor = n.parent;
100 }
101 return null;
102}
103
104// ── 1. TRIGGER REVIEW ──
105// Called by afterNote hook. Sends review request to partner.
106
107export async function triggerReview(nodeId, note, userId) {
108 const node = await _Node.findById(nodeId);
109 if (!node) return;
110
111 const config = getReviewConfig(node);
112 if (!config.partner) return;
113 if (config.status !== "idle") return;
114 if (config.trigger !== "afterNote") return;
115
116 // Validate partner exists
117 const partner = await _Node.findById(config.partner).select("systemRole").lean();
118 if (!partner) {
119 log.warn("PeerReview", `Partner ${config.partner} not found for node ${nodeId}`);
120 await addHistoryEntry(node, {
121 id: uuidv4(), noteId: String(note._id), partnerId: config.partner,
122 rounds: [], finalVerdict: null, error: "partner_not_found",
123 startedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
124 });
125 return;
126 }
127 if (partner.systemRole) {
128 log.warn("PeerReview", `Partner ${config.partner} is a system node`);
129 return;
130 }
131
132 const reviewId = uuidv4();
133 const content = truncate(note.content || "", MAX_CONTENT_CHARS);
134 if (!content) return;
135
136 // Set status to reviewing and record the session
137 const historyEntry = {
138 id: reviewId, noteId: String(note._id), notePreview: truncate(content, 200),
139 partnerId: config.partner, rounds: [], finalVerdict: null,
140 startedAt: new Date().toISOString(), completedAt: null,
141 };
142 const history = Array.isArray(config.history) ? [...config.history] : [];
143 history.push(historyEntry);
144 while (history.length > MAX_HISTORY_ENTRIES) history.shift();
145 await _setExtMeta(node, "peer-review", {
146 ...config, status: "reviewing", currentReviewId: reviewId, history,
147 });
148
149 // Build and send cascade signal
150 const payload = {
151 action: "peer-review:request",
152 tags: ["peer-review"],
153 reviewId,
154 sourceNodeId: nodeId,
155 targetNodeId: config.partner,
156 noteId: String(note._id),
157 noteContent: content,
158 round: 1,
159 reviewPrompt: config.reviewPrompt || null,
160 };
161
162 let result;
163 try {
164 result = await _deliverCascade({
165 nodeId: config.partner,
166 signalId: reviewId,
167 payload,
168 source: nodeId,
169 depth: 0,
170 });
171 } catch (err) {
172 log.warn("PeerReview", `Cascade delivery failed: ${err.message}`);
173 await setStatus(node, "idle", { currentReviewId: null });
174 return;
175 }
176
177 if (result?.status === "failed" || result?.status === "rejected") {
178 log.warn("PeerReview", `Cascade ${result.status}: ${result.payload?.reason || "unknown"}`);
179 // Record the failure
180 const freshNode = await _Node.findById(nodeId);
181 if (freshNode) {
182 const cfg = getReviewConfig(freshNode);
183 const sess = (cfg.history || []).find((h) => h.id === reviewId);
184 if (sess) { sess.error = result.payload?.reason; sess.completedAt = new Date().toISOString(); }
185 await _setExtMeta(freshNode, "peer-review", { ...cfg, status: "idle", currentReviewId: null });
186 }
187 return;
188 }
189
190 // Signal sent. Set awaiting-response.
191 const updated = await _Node.findById(nodeId);
192 if (updated) await setStatus(updated, "awaiting-response");
193}
194
195// ── 2. HANDLE REVIEW REQUEST ──
196// Called by onCascade at the reviewer node.
197
198export async function handleReviewRequest(hookData) {
199 const { nodeId, payload, depth } = hookData;
200 if (payload.targetNodeId !== nodeId) return; // not for us
201
202 const { reviewId, sourceNodeId, noteContent, round, reviewPrompt, revisedContent } = payload;
203 const contentToReview = revisedContent || noteContent;
204
205 const rootId = await resolveRoot(nodeId);
206
207 // Build the review message
208 let message = "";
209 if (reviewPrompt) message += `REVIEW INSTRUCTIONS: ${reviewPrompt}\n\n`;
210 message += `Review the following content from node ${sourceNodeId} (round ${round}):\n\n${contentToReview}`;
211
212 let answer;
213 try {
214 const result = await _runChat({
215 userId: "SYSTEM",
216 username: "peer-review",
217 message,
218 mode: "tree:review",
219 rootId,
220 nodeId,
221 slot: "peerReview",
222 });
223 answer = result?.answer;
224 } catch (err) {
225 log.warn("PeerReview", `Review AI call failed at ${nodeId}: ${err.message}`);
226 answer = null;
227 }
228
229 // Parse structured feedback
230 let feedback;
231 if (answer) {
232 feedback = parseJsonSafe(answer);
233 }
234 if (!feedback || !feedback.verdict) {
235 feedback = {
236 verdict: "approve",
237 confidence: 0,
238 suggestions: [],
239 summary: answer ? "Review produced non-structured output" : "Review AI call failed",
240 };
241 }
242
243 // Send response back to source
244 const responsePayload = {
245 action: "peer-review:response",
246 tags: ["peer-review"],
247 reviewId,
248 sourceNodeId: nodeId,
249 targetNodeId: sourceNodeId,
250 round,
251 verdict: feedback.verdict,
252 confidence: feedback.confidence || 0,
253 suggestions: Array.isArray(feedback.suggestions) ? feedback.suggestions : [],
254 summary: feedback.summary || "",
255 };
256
257 try {
258 await _deliverCascade({
259 nodeId: sourceNodeId,
260 signalId: reviewId,
261 payload: responsePayload,
262 source: nodeId,
263 depth: (depth || 0) + 1,
264 });
265 } catch (err) {
266 log.warn("PeerReview", `Response delivery failed: ${err.message}`);
267 }
268}
269
270// ── 3. HANDLE REVIEW RESPONSE ──
271// Called by onCascade at the source node (the one that requested review).
272
273export async function handleReviewResponse(hookData) {
274 const { nodeId, payload } = hookData;
275 if (payload.targetNodeId !== nodeId) return; // not for us
276
277 const { reviewId, round, verdict, suggestions, summary } = payload;
278
279 const node = await _Node.findById(nodeId);
280 if (!node) return;
281
282 const config = getReviewConfig(node);
283
284 // Verify this is the active review
285 if (config.currentReviewId !== reviewId) {
286 log.debug("PeerReview", `Stale review response at ${nodeId}: got ${reviewId}, expected ${config.currentReviewId}`);
287 return;
288 }
289
290 // Record this round in history
291 const roundData = {
292 round,
293 verdict,
294 summary: summary || "",
295 suggestions: Array.isArray(suggestions) ? suggestions.slice(0, 20) : [],
296 timestamp: new Date().toISOString(),
297 };
298 await addRoundToHistory(node, reviewId, roundData);
299
300 // Check if done
301 const maxRounds = config.maxRounds || 5;
302 const isDone = verdict === "approve" || verdict === "reject" || round >= maxRounds;
303
304 if (isDone || !config.autoApply) {
305 // Review complete
306 const freshNode = await _Node.findById(nodeId);
307 if (freshNode) {
308 const cfg = getReviewConfig(freshNode);
309 const sess = (cfg.history || []).find((h) => h.id === reviewId);
310 if (sess) {
311 sess.finalVerdict = verdict;
312 sess.completedAt = new Date().toISOString();
313 }
314 await _setExtMeta(freshNode, "peer-review", {
315 ...cfg, status: "idle", currentReviewId: null,
316 });
317 }
318
319 // Fire custom hook
320 if (_hooks) {
321 _hooks.run("peer-review:afterReview", {
322 nodeId, partnerId: config.partner,
323 feedback: { verdict, suggestions, summary },
324 round, consensus: verdict === "approve",
325 }).catch(() => {});
326 }
327
328 // Notify user
329 const label = verdict === "approve"
330 ? `Review approved after ${round} round(s).`
331 : verdict === "reject"
332 ? `Review rejected after ${round} round(s). ${summary}`
333 : round >= maxRounds
334 ? `Review hit max rounds (${maxRounds}). Last verdict: ${verdict}. ${summary}`
335 : `Review feedback (${round} round(s)): ${summary}`;
336
337 if (_emitToUser && config._lastUserId) {
338 _emitToUser(config._lastUserId, "peer-review:complete", {
339 nodeId, reviewId, verdict, round, summary, label,
340 });
341 }
342
343 log.verbose("PeerReview", `Review ${reviewId} at ${nodeId}: ${verdict} after ${round} round(s)`);
344 return;
345 }
346
347 // autoApply: revise and send next round
348 // Set status to "revising" BEFORE the revision happens.
349 // If the revision writes a note edit, afterNote fires and sees "revising", not "idle".
350 // This prevents re-triggering.
351 const revisingNode = await _Node.findById(nodeId);
352 if (revisingNode) await setStatus(revisingNode, "revising");
353
354 let revisedContent;
355 try {
356 revisedContent = await reviseContent(nodeId, config, suggestions, round);
357 } catch (err) {
358 log.warn("PeerReview", `Revision failed at ${nodeId}: ${err.message}`);
359 const errNode = await _Node.findById(nodeId);
360 if (errNode) {
361 const cfg = getReviewConfig(errNode);
362 const sess = (cfg.history || []).find((h) => h.id === reviewId);
363 if (sess) { sess.error = "revision_failed"; sess.completedAt = new Date().toISOString(); }
364 await _setExtMeta(errNode, "peer-review", { ...cfg, status: "idle", currentReviewId: null });
365 }
366 return;
367 }
368
369 // Send next round to reviewer
370 const nextPayload = {
371 action: "peer-review:request",
372 tags: ["peer-review"],
373 reviewId,
374 sourceNodeId: nodeId,
375 targetNodeId: config.partner,
376 noteId: payload.noteId || null,
377 noteContent: payload.noteContent || "",
378 revisedContent: truncate(revisedContent, MAX_CONTENT_CHARS),
379 round: round + 1,
380 reviewPrompt: config.reviewPrompt || null,
381 };
382
383 // Set status to reviewing before sending
384 const sendNode = await _Node.findById(nodeId);
385 if (sendNode) await setStatus(sendNode, "reviewing");
386
387 try {
388 await _deliverCascade({
389 nodeId: config.partner,
390 signalId: reviewId,
391 payload: nextPayload,
392 source: nodeId,
393 depth: (hookData.depth || 0) + 1,
394 });
395 } catch (err) {
396 log.warn("PeerReview", `Next round delivery failed: ${err.message}`);
397 const failNode = await _Node.findById(nodeId);
398 if (failNode) await setStatus(failNode, "idle", { currentReviewId: null });
399 return;
400 }
401
402 const awaitNode = await _Node.findById(nodeId);
403 if (awaitNode) await setStatus(awaitNode, "awaiting-response");
404}
405
406// ── 4. REVISE CONTENT ──
407// AI at the source node revises based on reviewer feedback.
408
409async function reviseContent(nodeId, config, suggestions, round) {
410 const rootId = await resolveRoot(nodeId);
411
412 // Load the original note to get current content
413 const sess = (config.history || []).find((h) => h.id === config.currentReviewId);
414 let originalContent = sess?.notePreview || "";
415 if (sess?.noteId) {
416 try {
417 const note = await _Note.findById(sess.noteId).select("content").lean();
418 if (note?.content) originalContent = truncate(note.content, MAX_CONTENT_CHARS);
419 } catch {}
420 }
421
422 const message = [
423 `You received peer review feedback (round ${round}). Revise your content based on the suggestions.`,
424 "",
425 "ORIGINAL:",
426 originalContent,
427 "",
428 "FEEDBACK:",
429 JSON.stringify(suggestions, null, 2),
430 "",
431 "Return ONLY the revised content. No explanations. No JSON wrapper. Just the revised text.",
432 ].join("\n");
433
434 const result = await _runChat({
435 userId: "SYSTEM",
436 username: "peer-review",
437 message,
438 mode: "tree:respond",
439 rootId,
440 nodeId,
441 slot: "peerReview",
442 });
443
444 return result?.answer || originalContent;
445}
446
447// ── Exports for routes/tools ──
448
449export function getReviewHistory(node, limit = 10) {
450 const config = getReviewConfig(node);
451 const history = Array.isArray(config.history) ? config.history : [];
452 return history.slice(-limit);
453}
454
1import log from "../../seed/log.js";
2import tools, { setMetadata as setToolMetadata } from "./tools.js";
3import {
4 setServices, triggerReview, handleReviewRequest,
5 handleReviewResponse, getReviewConfig,
6} from "./core.js";
7export async function init(core) {
8 setToolMetadata(core.metadata);
9 const { deliverCascade } = await import("../../seed/tree/cascade.js");
10 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
11
12 core.llm.registerRootLlmSlot?.("peerReview");
13
14 setServices({
15 runChat: async (opts) => {
16 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
17 return core.llm.runChat({ ...opts, llmPriority: BG });
18 },
19 deliverCascade,
20 setExtMeta: core.metadata.setExtMeta,
21 getExtMeta: core.metadata.getExtMeta,
22 mergeExtMeta: core.metadata.mergeExtMeta,
23 emitToUser: core.websocket?.emitToUser || (() => {}),
24 hooks: core.hooks,
25 Node: core.models.Node,
26 Note: core.models.Note,
27 });
28
29 // Register the review mode
30 core.modes.registerMode(
31 "tree:review",
32 (await import("./modes/review.js")).default,
33 "peer-review",
34 );
35 if (core.llm?.registerModeAssignment) {
36 core.llm.registerModeAssignment("tree:review", "review");
37 }
38
39 // ── afterNote: trigger review when content is written ──
40 core.hooks.register("afterNote", async ({ note, nodeId, userId, contentType, action }) => {
41 if (contentType !== "text") return;
42 if (action !== "create" && action !== "edit") return;
43 if (!userId || userId === "SYSTEM") return;
44
45 const node = await core.models.Node.findById(nodeId).select("systemRole metadata").lean();
46 if (!node || node.systemRole) return;
47
48 const config = getReviewConfig(node);
49 if (!config.partner) return;
50 if (config.status !== "idle") return;
51 if (config.trigger !== "afterNote") return;
52
53 // Track userId for notification on completion
54 try {
55 const fullNode = await core.models.Node.findById(nodeId);
56 if (fullNode) {
57 const cfg = getReviewConfig(fullNode);
58 await core.metadata.setExtMeta(fullNode, "peer-review", { ...cfg, _lastUserId: userId });
59 }
60 } catch {}
61
62 triggerReview(nodeId, note, userId).catch((err) => {
63 log.debug("PeerReview", `Background trigger failed: ${err.message}`);
64 });
65 }, "peer-review");
66
67 // ── onCascade: receive review requests and responses ──
68 core.hooks.register("onCascade", async (hookData) => {
69 const { payload } = hookData;
70 if (!payload || typeof payload !== "object") return;
71 if (!Array.isArray(payload.tags) || !payload.tags.includes("peer-review")) return;
72
73 try {
74 if (payload.action === "peer-review:request") {
75 await handleReviewRequest(hookData);
76 hookData._resultExtName = "peer-review";
77 } else if (payload.action === "peer-review:response") {
78 await handleReviewResponse(hookData);
79 hookData._resultExtName = "peer-review";
80 }
81 } catch (err) {
82 log.warn("PeerReview", `onCascade handler failed: ${err.message}`);
83 }
84 }, "peer-review");
85
86 // ── enrichContext: surface review state to the AI ──
87 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
88 const review = meta?.["peer-review"];
89 if (!review || typeof review !== "object") return;
90
91 const enrichment = {};
92
93 if (review.partner) {
94 enrichment.reviewPartner = review.partner;
95 enrichment.reviewStatus = review.status || "idle";
96 }
97
98 if (review.status === "awaiting-response" || review.status === "reviewing" || review.status === "revising") {
99 enrichment.pendingReview = true;
100 }
101
102 // Last completed review summary
103 const history = Array.isArray(review.history) ? review.history : [];
104 const lastCompleted = history.filter((h) => h.completedAt).slice(-1)[0];
105 if (lastCompleted) {
106 const lastRound = lastCompleted.rounds?.[lastCompleted.rounds.length - 1];
107 enrichment.lastReview = {
108 verdict: lastCompleted.finalVerdict,
109 completedAt: lastCompleted.completedAt,
110 summary: lastRound?.summary || null,
111 rounds: lastCompleted.rounds?.length || 0,
112 };
113 }
114
115 if (Object.keys(enrichment).length > 0) {
116 context.peerReview = enrichment;
117 }
118 }, "peer-review");
119
120 const { default: router, setMetadata: setRouteMetadata } = await import("./routes.js");
121 setRouteMetadata(core.metadata);
122
123 return {
124 router,
125 tools,
126 exports: {
127 triggerReview,
128 getReviewConfig,
129 },
130 };
131}
132
1export default {
2 name: "peer-review",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "Structured AI-to-AI peer review between nodes. Set a review partner on any node. " +
7 "When a note is written, the content cascades to the partner. The AI at the partner " +
8 "reviews the work and returns structured feedback. If autoApply is true, the source " +
9 "AI revises and sends back for re-review. Loop until consensus or maxRounds.",
10
11 needs: {
12 services: ["hooks", "llm", "metadata"],
13 models: ["Node", "Note"],
14 extensions: [],
15 },
16
17 optional: {
18 extensions: ["propagation", "codebook"],
19 },
20
21 provides: {
22 models: {},
23 routes: "./routes.js",
24 tools: true,
25 jobs: false,
26 energyActions: {},
27 sessionTypes: {},
28 env: [],
29
30 modes: [
31 {
32 key: "tree:review",
33 handler: "./modes/review.js",
34 assignmentSlot: "review",
35 },
36 ],
37
38 cli: [
39 {
40 command: "review [action] [args...]", scope: ["tree"],
41 description: "Peer review status and control",
42 method: "GET",
43 endpoint: "/node/:nodeId/review/status",
44 subcommands: {
45 "set-partner": {
46 method: "POST",
47 endpoint: "/node/:nodeId/review/partner",
48 args: ["partnerId"],
49 description: "Set the node that reviews this node's work",
50 },
51 clear: {
52 method: "DELETE",
53 endpoint: "/node/:nodeId/review",
54 description: "Remove review config from this node",
55 },
56 history: {
57 method: "GET",
58 endpoint: "/node/:nodeId/review/history",
59 description: "Show review history at this position",
60 },
61 pause: {
62 method: "POST",
63 endpoint: "/node/:nodeId/review/pause",
64 description: "Pause automatic reviews",
65 },
66 resume: {
67 method: "POST",
68 endpoint: "/node/:nodeId/review/resume",
69 description: "Resume automatic reviews",
70 },
71 apply: {
72 method: "POST",
73 endpoint: "/node/:nodeId/review/apply",
74 description: "Apply pending review feedback",
75 },
76 dismiss: {
77 method: "POST",
78 endpoint: "/node/:nodeId/review/dismiss",
79 description: "Dismiss pending review feedback",
80 },
81 },
82 },
83 ],
84 },
85};
86
1export default {
2 name: "tree:review",
3 emoji: "R",
4 label: "Peer Review",
5 bigMode: "tree",
6 hidden: true,
7 maxMessagesBeforeLoop: 2,
8 preserveContextOnLoop: false,
9 toolNames: [],
10
11 buildSystemPrompt({ username, rootId, currentNodeId }) {
12 return [
13 `You are a peer reviewer at node ${currentNodeId || "unknown"} in tree ${rootId || "unknown"}.`,
14 "",
15 "You received work from another node. Read it carefully. Compare against everything you know",
16 "at your position. Find what is wrong. Find what is missing. Find what contradicts.",
17 "Be direct.",
18 "",
19 "REVIEW CRITERIA:",
20 "1. Accuracy: Are claims correct? Are there unsupported assertions?",
21 "2. Completeness: Does it cover what it should? What is missing?",
22 "3. Clarity: Is the writing clear and unambiguous?",
23 "4. Consistency: Does the content align with what you know at your position?",
24 "5. Structure: Is it well organized?",
25 "",
26 "OUTPUT FORMAT: Return ONLY a JSON object. No prose before or after.",
27 "",
28 "{",
29 ' "verdict": "approve" | "revise" | "reject",',
30 ' "confidence": 0.0-1.0,',
31 ' "suggestions": [',
32 " {",
33 ' "type": "accuracy" | "completeness" | "clarity" | "consistency" | "structure",',
34 ' "severity": "minor" | "moderate" | "major",',
35 ' "location": "quote or description of where in the content",',
36 ' "issue": "what is wrong",',
37 ' "suggestion": "how to fix it"',
38 " }",
39 " ],",
40 ' "summary": "one sentence overall assessment"',
41 "}",
42 "",
43 "RULES:",
44 '- "approve" means no changes needed. suggestions should be empty or minor only.',
45 '- "revise" means changes needed. At least one moderate or major suggestion.',
46 '- "reject" means fundamentally flawed. Use sparingly.',
47 '- Be specific. "This could be better" is not useful. "The claim about X contradicts Y" is.',
48 "- Do not invent issues. If the content is good, approve it.",
49 "- Match review depth to the content's importance and length.",
50 ].join("\n");
51 },
52};
53
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import Node from "../../seed/models/node.js";
5import { getReviewConfig, getReviewHistory } from "./core.js";
6
7let _metadata = null;
8export function setMetadata(metadata) { _metadata = metadata; }
9
10const router = express.Router();
11
12// GET /node/:nodeId/review/status
13router.get("/node/:nodeId/review/status", authenticate, async (req, res) => {
14 try {
15 const node = await Node.findById(req.params.nodeId).select("metadata").lean();
16 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
17
18 const config = getReviewConfig(node);
19 if (!config.partner) return sendOk(res, { configured: false });
20
21 let partnerName = null;
22 try {
23 const p = await Node.findById(config.partner).select("name").lean();
24 partnerName = p?.name || null;
25 } catch {}
26
27 sendOk(res, {
28 configured: true,
29 partner: { nodeId: config.partner, name: partnerName },
30 status: config.status,
31 maxRounds: config.maxRounds,
32 autoApply: config.autoApply,
33 reviewPrompt: config.reviewPrompt || null,
34 currentReviewId: config.currentReviewId || null,
35 reviewsCompleted: (config.history || []).filter((h) => h.completedAt).length,
36 });
37 } catch (err) {
38 sendError(res, 500, ERR.INTERNAL, err.message);
39 }
40});
41
42// POST /node/:nodeId/review/partner
43router.post("/node/:nodeId/review/partner", authenticate, async (req, res) => {
44 try {
45 const { partnerId, maxRounds, autoApply, reviewPrompt } = req.body;
46 if (!partnerId) return sendError(res, 400, ERR.INVALID_INPUT, "partnerId required");
47
48 const node = await Node.findById(req.params.nodeId);
49 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
50 if (node.systemRole) return sendError(res, 403, ERR.FORBIDDEN, "Cannot set review on system nodes");
51
52 if (partnerId === req.params.nodeId) {
53 return sendError(res, 400, ERR.INVALID_INPUT, "A node cannot review itself");
54 }
55
56 const partner = await Node.findById(partnerId).select("name systemRole").lean();
57 if (!partner) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Partner node not found");
58 if (partner.systemRole) return sendError(res, 403, ERR.FORBIDDEN, "Cannot use system node as reviewer");
59
60 const existing = getReviewConfig(node);
61 const config = {
62 ...existing,
63 partner: partnerId,
64 trigger: "afterNote",
65 status: existing.status === "paused" ? "paused" : "idle",
66 };
67 if (maxRounds !== undefined) config.maxRounds = Math.max(1, Math.min(Number(maxRounds) || 5, 20));
68 if (autoApply !== undefined) config.autoApply = !!autoApply;
69 if (reviewPrompt !== undefined) config.reviewPrompt = reviewPrompt || null;
70
71 await _metadata.setExtMeta(node, "peer-review", config);
72 sendOk(res, { partner: { nodeId: partnerId, name: partner.name }, config });
73 } catch (err) {
74 sendError(res, 500, ERR.INTERNAL, err.message);
75 }
76});
77
78// DELETE /node/:nodeId/review
79router.delete("/node/:nodeId/review", authenticate, async (req, res) => {
80 try {
81 const node = await Node.findById(req.params.nodeId);
82 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
83
84 const config = getReviewConfig(node);
85 if (!config.partner) return sendOk(res, { message: "No review configuration to remove" });
86
87 await _metadata.setExtMeta(node, "peer-review", null);
88 sendOk(res, { message: "Review configuration removed" });
89 } catch (err) {
90 sendError(res, 500, ERR.INTERNAL, err.message);
91 }
92});
93
94// GET /node/:nodeId/review/history
95router.get("/node/:nodeId/review/history", authenticate, async (req, res) => {
96 try {
97 const node = await Node.findById(req.params.nodeId).select("metadata").lean();
98 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
99
100 const limit = Math.max(1, Math.min(Number(req.query.limit) || 10, 50));
101 const history = getReviewHistory(node, limit);
102 sendOk(res, { count: history.length, history });
103 } catch (err) {
104 sendError(res, 500, ERR.INTERNAL, err.message);
105 }
106});
107
108// POST /node/:nodeId/review/pause
109router.post("/node/:nodeId/review/pause", authenticate, async (req, res) => {
110 try {
111 const node = await Node.findById(req.params.nodeId);
112 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
113
114 const config = getReviewConfig(node);
115 if (!config.partner) return sendError(res, 400, ERR.INVALID_INPUT, "No review partner configured");
116
117 await _metadata.setExtMeta(node, "peer-review", { ...config, status: "paused" });
118 sendOk(res, { status: "paused" });
119 } catch (err) {
120 sendError(res, 500, ERR.INTERNAL, err.message);
121 }
122});
123
124// POST /node/:nodeId/review/resume
125router.post("/node/:nodeId/review/resume", authenticate, async (req, res) => {
126 try {
127 const node = await Node.findById(req.params.nodeId);
128 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
129
130 const config = getReviewConfig(node);
131 if (!config.partner) return sendError(res, 400, ERR.INVALID_INPUT, "No review partner configured");
132
133 await _metadata.setExtMeta(node, "peer-review", { ...config, status: "idle" });
134 sendOk(res, { status: "idle" });
135 } catch (err) {
136 sendError(res, 500, ERR.INTERNAL, err.message);
137 }
138});
139
140export default router;
141
1import { z } from "zod";
2import Node from "../../seed/models/node.js";
3import { getReviewConfig, getReviewHistory } from "./core.js";
4
5let _metadata = null;
6export function setMetadata(metadata) { _metadata = metadata; }
7
8export default [
9 {
10 name: "peer-review-set-partner",
11 description:
12 "Set a review partner for a node. When a note is written at this node, the content " +
13 "is sent to the partner node for AI review. The partner's AI reviews against its own " +
14 "context and returns structured feedback.",
15 schema: {
16 nodeId: z.string().describe("The node to configure review for."),
17 partnerId: z.string().describe("The node that will review this node's work."),
18 maxRounds: z.number().optional().describe("Max consensus loop rounds. Default 5."),
19 autoApply: z.boolean().optional().describe("Automatically revise based on feedback. Default false."),
20 reviewPrompt: z.string().nullable().optional().describe("Custom review instructions. Null for default."),
21 userId: z.string().describe("Injected by server. Ignore."),
22 },
23 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
24 handler: async ({ nodeId, partnerId, maxRounds, autoApply, reviewPrompt }) => {
25 const node = await Node.findById(nodeId);
26 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
27 if (node.systemRole) return { content: [{ type: "text", text: "Cannot set review on system nodes." }] };
28
29 const partner = await Node.findById(partnerId).select("name systemRole").lean();
30 if (!partner) return { content: [{ type: "text", text: "Partner node not found." }] };
31 if (partner.systemRole) return { content: [{ type: "text", text: "Cannot use system node as reviewer." }] };
32 if (partnerId === nodeId) return { content: [{ type: "text", text: "A node cannot review itself." }] };
33
34 const existing = getReviewConfig(node);
35 const config = {
36 ...existing,
37 partner: partnerId,
38 trigger: "afterNote",
39 status: existing.status === "paused" ? "paused" : "idle",
40 };
41 if (maxRounds !== undefined) config.maxRounds = Math.max(1, Math.min(maxRounds, 20));
42 if (autoApply !== undefined) config.autoApply = autoApply;
43 if (reviewPrompt !== undefined) config.reviewPrompt = reviewPrompt;
44
45 await _metadata.setExtMeta(node, "peer-review", config);
46
47 return {
48 content: [{
49 type: "text",
50 text: `Review partner set to "${partner.name}" (${partnerId}). Notes written here will be reviewed by the AI at that position.` +
51 (config.autoApply ? " Auto-apply is ON. Revisions will loop until consensus." : " Feedback will be surfaced to the user."),
52 }],
53 };
54 },
55 },
56
57 {
58 name: "peer-review-status",
59 description: "Show the current peer review configuration and status at a node.",
60 schema: {
61 nodeId: z.string().describe("The node to check."),
62 userId: z.string().describe("Injected by server. Ignore."),
63 },
64 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
65 handler: async ({ nodeId }) => {
66 const node = await Node.findById(nodeId).select("metadata").lean();
67 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
68
69 const config = getReviewConfig(node);
70 if (!config.partner) {
71 return { content: [{ type: "text", text: "No review partner configured at this node." }] };
72 }
73
74 let partnerName = config.partner;
75 try {
76 const p = await Node.findById(config.partner).select("name").lean();
77 if (p?.name) partnerName = `${p.name} (${config.partner})`;
78 } catch {}
79
80 return {
81 content: [{
82 type: "text",
83 text: JSON.stringify({
84 partner: partnerName,
85 status: config.status,
86 maxRounds: config.maxRounds,
87 autoApply: config.autoApply,
88 reviewPrompt: config.reviewPrompt || "(default)",
89 reviewsCompleted: (config.history || []).filter((h) => h.completedAt).length,
90 currentReviewId: config.currentReviewId || null,
91 }, null, 2),
92 }],
93 };
94 },
95 },
96
97 {
98 name: "peer-review-history",
99 description: "Show past review sessions at a node. Each session includes rounds, verdicts, and summaries.",
100 schema: {
101 nodeId: z.string().describe("The node to check."),
102 limit: z.number().optional().describe("Number of recent sessions. Default 5."),
103 userId: z.string().describe("Injected by server. Ignore."),
104 },
105 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
106 handler: async ({ nodeId, limit }) => {
107 const node = await Node.findById(nodeId).select("metadata").lean();
108 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
109
110 const history = getReviewHistory(node, limit || 5);
111 if (history.length === 0) {
112 return { content: [{ type: "text", text: "No review history at this node." }] };
113 }
114
115 return {
116 content: [{ type: "text", text: JSON.stringify(history, null, 2) }],
117 };
118 },
119 },
120
121 {
122 name: "peer-review-clear",
123 description: "Remove review configuration from a node. Stops all future reviews.",
124 schema: {
125 nodeId: z.string().describe("The node to clear review from."),
126 userId: z.string().describe("Injected by server. Ignore."),
127 },
128 annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
129 handler: async ({ nodeId }) => {
130 const node = await Node.findById(nodeId);
131 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
132
133 await _metadata.setExtMeta(node, "peer-review", null);
134 return { content: [{ type: "text", text: "Review configuration removed." }] };
135 },
136 },
137
138 {
139 name: "peer-review-pause",
140 description: "Pause automatic reviews at a node. Keeps configuration. Resume later.",
141 schema: {
142 nodeId: z.string().describe("The node to pause reviews on."),
143 userId: z.string().describe("Injected by server. Ignore."),
144 },
145 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
146 handler: async ({ nodeId }) => {
147 const node = await Node.findById(nodeId);
148 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
149
150 const config = getReviewConfig(node);
151 if (!config.partner) return { content: [{ type: "text", text: "No review partner configured." }] };
152
153 await _metadata.setExtMeta(node, "peer-review", { ...config, status: "paused" });
154 return { content: [{ type: "text", text: "Reviews paused. Notes will not trigger review until resumed." }] };
155 },
156 },
157
158 {
159 name: "peer-review-resume",
160 description: "Resume automatic reviews at a node after pausing.",
161 schema: {
162 nodeId: z.string().describe("The node to resume reviews on."),
163 userId: z.string().describe("Injected by server. Ignore."),
164 },
165 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
166 handler: async ({ nodeId }) => {
167 const node = await Node.findById(nodeId);
168 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
169
170 const config = getReviewConfig(node);
171 if (!config.partner) return { content: [{ type: "text", text: "No review partner configured." }] };
172
173 await _metadata.setExtMeta(node, "peer-review", { ...config, status: "idle" });
174 return { content: [{ type: "text", text: "Reviews resumed. Next note write will trigger review." }] };
175 },
176 },
177];
178
Loading comments...