import { Type } from "typebox"; import { installRepairGate } from "./repair.js"; // The composition root supplies manifest language; only deterministic chrome is localized. function normalizedText(value) { return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase(); } const MEMORY_ID_RE = /\bmem-(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\d{4,})\b/i; function memoryOptionKey(option) { const searchable = [ option.id, option.label, option.description, option.decision?.rationale, ].join(" "); const memoryId = searchable.match(MEMORY_ID_RE)?.[0]?.toLowerCase(); if (memoryId) return `id:${memoryId}`; return [ "content", normalizedText(option.decision?.type), normalizedText(option.decision?.subject), normalizedText(option.decision?.detail), ].join(":"); } function normalizeMemoryOptions(options, locale, gateText) { const seen = new Set(); const out = []; for (const option of options) { if (option.decision?.type !== "concept_clarified") continue; const key = memoryOptionKey(option); if (seen.has(key)) continue; seen.add(key); const memoryId = [ option.id, option.label, option.description, option.decision?.rationale, ].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase(); const fallbackDetail = [ option.decision?.type && option.decision?.subject ? `${gateText(locale, "decision")} ${option.decision.type}: ${option.decision.subject}` : "", option.decision?.detail ?? "", ].filter(Boolean).join("\n"); out.push({ ...option, detail: option.description?.trim() || fallbackDetail, rationale: option.decision?.rationale ?? "", ...(memoryId ? { meta: { memory_id: memoryId } } : {}), }); } return out; } function memorySelectionWidgetProps(options, locale, gateText) { return { title: gateText(locale, "memoryTitle"), titleI18n: gateText("en", "memoryTitle"), selected: options.filter((option) => option.recommended).map((option) => option.id), selectionLabel: gateText(locale, "memorySelection"), confirmLabel: gateText(locale, "memoryApply"), selectionLabelI18n: gateText("en", "memorySelection"), confirmLabelI18n: gateText("en", "memoryApply"), }; } function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) { return meritCount === 0 && !!allowEmpty && !!advance; } async function reviewRecall(ctx, params, phase, dependencies) { const { workflow, ledger, reviewer, waitForReviewer, toTextResult, languageFor, gateText } = dependencies; try { const { session, advance } = params; const locale = languageFor(ctx, session); const options = normalizeMemoryOptions(params.options, locale, gateText); const typeError = ledger.validate(ctx, options, session); if (typeError) return toTextResult(typeError); const meritOptions = options .filter((option) => !reviewer.isReserved(option.label)) .map((option) => ({ id: option.id, label: option.label, ...(option.detail ? { detail: option.detail } : {}), ...(option.rationale ? { rationale: option.rationale } : {}), ...(option.meta ? { meta: option.meta } : {}), })); if (shouldSkipEmptyRecall({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance, })) { await ctx.ui.notify( gateText(locale, "memoryEmpty"), "info", ); workflow.advance(ctx, session); return toTextResult( "Fase memoria vuota: nessuna decisione da registrare, " + "avanzamento automatico alla fase successiva.", ); } const widget = reviewer.buildMultiselect({ id: `u${Date.now()}`, phase, allowEmpty: params.allow_empty ?? false, options: meritOptions, ...memorySelectionWidgetProps(options, locale, gateText), }); const response = await waitForReviewer(ctx, widget); if (response.control === "freetext") { return toTextResult( `Altro (reviewer): ${response.text}. Riformula la proposta tenendo conto.`, ); } if (response.control === "back") { return toTextResult("Il reviewer vuole tornare indietro."); } if (response.control === "exit") { return toTextResult("Il reviewer vuole uscire."); } const chosen = options.filter((option) => (response.choices ?? []).includes(option.id)); const recorded = []; for (const choice of chosen) { const error = ledger.record(ctx, session, choice.decision, ""); if (error) return error; recorded.push(choice.decision); } if (!recorded.length) { const result = advance ? workflow.advance(ctx, session) : { advanced: false }; const message = "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito)."; return toTextResult( result.advanced ? message + " Fase avanzata automaticamente." : message, ); } const result = advance ? workflow.advance(ctx, session) : { advanced: false }; const parts = [ `Registrate ${recorded.length} decisioni: ` + recorded.map((decision) => decision.type).join(", ") + ".", ]; parts.push(result.advanced ? "Fase avanzata automaticamente." : 'La fase resta aperta: chiudila con reviewer_confirm kind:"phase" quando pronta.'); return toTextResult(parts.join(" ")); } catch (fatal) { const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim(); return toTextResult( `[reviewer_decide ERRORE INTERNO] ${message}. Riprova o usa un approccio diverso.`, ); } } function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult, languageFor, gateText }) { pi.registerTool({ name: "reviewer_memory_promote", label: "Review Memory summary", description: "F8: show the editable Memory summary from persisted proposals and approved " + "decisions. The reviewer selects additions, updates and links. Only those choices " + "are saved. Then close F8 and finalize. Prepare reusable rules during the workflow " + "with tht memory propose; do not call review-apply yourself.", parameters: Type.Object({ session: Type.String() }), async execute(_id, params, _signal, _onUpdate, ctx) { workflow.activate(); try { const { session } = params; const phase = workflow.phase(ctx, session); if (phase.number > 8) return workflow.close(ctx, session, phase.number, "Memory reviewed."); if (phase.number !== 8) return toTextResult("Review the Memory summary at the end of F8."); const summary = JSON.parse(memory.execute(ctx, ["summary", "--session", session, "--json"])); if (summary.reviewed) { const failure = ledger.record(ctx, session, { type: "memory_summary_reviewed", subject: summary.summary_id, detail: "Previously saved Memory review recovered.", }, "Retry to record the recovered Memory review."); if (failure) return failure; return workflow.close(ctx, session, phase.number, "Memory review recovered."); } const response = await waitForReviewer(ctx, { id: `u${Date.now()}`, schema_version: 1, phase: phase.id, widget: "memory-review", title: gateText(languageFor(ctx, session), "memorySummary"), title_i18n: gateText("en", "memorySummary"), summary, reserved: ["other", "back", "exit"], }); if (response.control) return toTextResult( response.control === "freetext" ? `Reviewer feedback: ${response.text}. Revise the proposals and present the summary again.` : `Reviewer requested: ${response.control}.`); const selection = JSON.parse(response.text || "{}"); if (selection.summary_id !== summary.summary_id || !Array.isArray(selection.items)) return toTextResult("Invalid Memory review response. Present the summary again."); const failure = await memory.mutate(ctx, [ "review-apply", "--session", session, "--review-json", JSON.stringify(selection), "--json", ], "The Memory review is recoverable. Present the summary again if its content changed."); if (failure) return failure; const selected = new Set(selection.items.map(item => item.id)); const ledgerFailure = ledger.record(ctx, session, { type: "memory_summary_reviewed", subject: summary.summary_id, detail: `Saved ${selected.size} cards; declined ${summary.items.length-selected.size}.`, }, "Memory is saved. Retry to record the review and finalize."); if (ledgerFailure) return ledgerFailure; const message = `Memory reviewed: ${selected.size} cards saved, ${summary.items.length-selected.size} declined.`; return workflow.close(ctx, session, phase.number, message) || toTextResult(message); } catch (error) { return toTextResult(`[reviewer_memory_promote] ${String(error.stderr || error.message || error).trim()}`); } }, }); } export function createMemoryGate(dependencies) { return { install: (pi) => { installMemoryGate(pi, dependencies); installRepairGate(pi, dependencies); }, reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies), }; }