211 lines
7.2 KiB
JavaScript
211 lines
7.2 KiB
JavaScript
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.",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
}
|