import { Type } from "typebox"; // Compatibility note: reviewer labels move verbatim from the composition root. // Tickets #22 and #23 require observable parity; translating existing chrome is a // separate product behavior change rather than part of these extractions. function normalizedText(value) { return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase(); } const MEMORY_ID_RE = /\bmem-\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) { 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 ? `Decisione ${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) { return { title: "Seleziona le memory da applicare alla domanda", selected: options.filter((option) => option.recommended).map((option) => option.id), selectionLabel: "memory da applicare", confirmLabel: "Applica le memory selezionate", }; } function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) { return meritCount === 0 && !!allowEmpty && !!advance; } async function reviewRecall(ctx, params, phase, dependencies) { const { workflow, ledger, reviewer, waitForReviewer, toTextResult } = dependencies; try { const { session, advance } = params; const options = normalizeMemoryOptions(params.options); 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( "Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.", "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), }); 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.`, ); } } // The candidates come from `tht memory promote --preview --json` (deterministic, // reviewer-approved decisions only); the model never authors them. function dedupePromotionCandidates(candidates) { const seen = new Set(); return candidates.filter((candidate) => { if (candidate.type !== "concept_clarified") return false; const key = [ candidate.type, candidate.subject, candidate.detail, candidate.rationale, candidate.question_context, ].map(normalizedText).join("\u0000"); if (seen.has(key)) return false; seen.add(key); return true; }); } function promotionOptions(candidates) { return candidates.map((candidate) => ({ id: `seq-${candidate.decision_seq}`, label: `${candidate.type}: ${candidate.subject}`, detail: candidate.detail || "", rationale: candidate.rationale || "", meta: { question_context: candidate.question_context || "" }, })); } function promotionContent(candidates) { return candidates .map( (candidate) => `- **${candidate.type}: ${candidate.subject}** (decisione #${candidate.decision_seq})\n` + ` ${candidate.detail || ""}\n` + ` Motivo: ${candidate.rationale || "—"}\n` + ` Domanda di contesto: ${candidate.question_context || "—"}`, ) .join("\n"); } function splitPromotionChoices(candidates, choices) { const chosen = new Set(choices ?? []); const promote = []; const decline = []; for (const candidate of candidates) { (chosen.has(`seq-${candidate.decision_seq}`) ? promote : decline).push(candidate); } return { promote, decline }; } /** * Install the public F8 Memory tool against the narrow capability supplied by the * workflow composition root. Memory owns candidate policy, presentation and mutation * order; the capability keeps shared phase, ledger and persistence mechanisms in core. */ function installMemoryGate( pi, { workflow, memory, ledger, reviewer, waitForReviewer, toTextResult }, ) { pi.registerTool({ name: "reviewer_memory_promote", label: "Promozione memorie riusabili (reviewer)", description: "F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " + "calcolati dalla CLI (tht memory promote --preview: solo concept_clarified, " + "max 5, esclusi i gia' promossi/rifiutati). Le selezioni " + "vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " + "le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " + "alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " + "promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " + "presentare un reviewer_confirm dopo.", 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); let candidates; try { candidates = JSON.parse(memory.execute( ctx, ["promote", "--session", session, "--preview", "--json"], )); } catch (error) { const message = (error.stderr || error.message || String(error)).toString().trim(); return toTextResult(`Preview di promozione non disponibile: ${message}`); } if (!Array.isArray(candidates) || candidates.length === 0) { await ctx.ui.notify( "Nessuna decisione riusabile da promuovere in memoria per questa sessione.", "info", ); const closed = workflow.close( ctx, session, phase.number, "Nessun candidato di promozione.", ); if (closed) return closed; return toTextResult( "Nessun candidato di promozione: prosegui con la chiusura della sessione.", ); } candidates = dedupePromotionCandidates(candidates); if (candidates.length === 0) { await ctx.ui.notify( "Nessun concetto chiarito da salvare come memory per questa sessione.", "info", ); const closed = workflow.close( ctx, session, phase.number, "Nessun candidato di promozione.", ); if (closed) return closed; return toTextResult( "Nessun candidato di promozione: prosegui con la chiusura della sessione.", ); } const options = promotionOptions(candidates); const widget = reviewer.buildMultiselect({ id: `u${Date.now()}`, phase: phase.id, title: "Quali concetti chiariti salvare nella memoria riutilizzabile?", allowEmpty: true, options, selected: options.map((option) => option.id), content: promotionContent(candidates), }); const response = await waitForReviewer(ctx, widget); if (response.control === "freetext") { return toTextResult( `Altro (reviewer): ${response.text}. Valuta e ripresenta il gate.`, ); } if (response.control === "back") { return toTextResult("Il reviewer vuole tornare indietro."); } if (response.control === "exit") { return toTextResult("Il reviewer vuole uscire."); } const { promote, decline } = splitPromotionChoices(candidates, response.choices); let saved = 0; for (const candidate of promote) { const saveError = memory.mutate( ctx, [ "save-one", "--session", session, "--decision", String(candidate.decision_seq), "--json", ], `Recupero manuale (umano): tht memory save-one --session ${session} ` + `--decision ${candidate.decision_seq}. Finora salvate: ${saved}.`, ); if (saveError) return saveError; const ledgerError = ledger.record( ctx, session, { type: "memory_promoted", subject: candidate.subject, detail: `seq:${candidate.decision_seq}`, rationale: candidate.rationale || candidate.detail || "", }, "Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: " + `recupero manuale (umano) con tht decision add --session ${session} ` + `--type memory_promoted --subject "${candidate.subject}" ` + `--detail seq:${candidate.decision_seq}.`, ); if (ledgerError) return ledgerError; saved++; } for (const candidate of decline) { const ledgerError = ledger.record(ctx, session, { type: "memory_promotion_declined", subject: candidate.subject, detail: `seq:${candidate.decision_seq}`, }, ""); if (ledgerError) return ledgerError; } const summary = `Promozione registrata: ${saved} memorie salvate nel vectordb, ` + `${decline.length} candidati scartati.`; const closed = workflow.close(ctx, session, phase.number, summary); if (closed) return closed; return toTextResult(summary); } catch (fatal) { const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim(); return toTextResult( `[reviewer_memory_promote ERRORE INTERNO] ${message}. ` + "Riprova o usa un approccio diverso.", ); } }, }); } export function createMemoryGate(dependencies) { return { install: (pi) => installMemoryGate(pi, dependencies), reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies), }; }