EXTENSION seed
peer-review
Structured AI-to-AI peer review between nodes. Set a review partner on any node. When a note is written, the content cascades to the partner. The AI at the partner reviews the work and returns structured feedback. If autoApply is true, the source AI revises and sends back for re-review. Loop until consensus or maxRounds.
v1.0.1 by TreeOS Site 0 downloads 6 files 1,044 lines 35.5 KB published 38d ago
treeos ext install peer-review
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks, llm, metadata
  • models: Node, Note

Optional

  • extensions: propagation, codebook
SHA256: 7aa801eeada9351ee230a7f63a10fa715f9d5714cc34c426e054d979b65b7f5c

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
reviewGETPeer review status and control
review set-partnerPOSTSet the node that reviews this node's work
review clearDELETERemove review config from this node
review historyGETShow review history at this position
review pausePOSTPause automatic reviews
review resumePOSTResume automatic reviews
review applyPOSTApply pending review feedback
review dismissPOSTDismiss pending review feedback

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star peer-review

Comments

Loading comments...

Post comments from the CLI: treeos ext comment peer-review "your comment"
Max 3 comments per extension. One star and one flag per user.