refactor(memory): extract F8 promotion gate (#22)

This commit is contained in:
2026-08-24 00:51:53 +02:00
parent d15bb59c3d
commit beac2e80f4
3 changed files with 379 additions and 217 deletions
+210
View File
@@ -0,0 +1,210 @@
import { Type } from "typebox";
import { buildMultiselectRequest } from "../builders.js";
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
}
// 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.
*/
export function installMemoryGate(
pi,
{ workflow, memory, ledger, waitForReviewer, toTextResult },
) {
// Compatibility note: these labels move verbatim from the composition root.
// Ticket #22 requires byte-for-byte observable parity; translating existing chrome
// is a separate product behavior change rather than part of this extraction.
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 = buildMultiselectRequest({
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.",
);
}
},
});
}