371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
import { Type } from "typebox";
|
|
|
|
import { buildMultiselectRequest } from "../builders.js";
|
|
import { isReserved } from "../../reserved-labels.mjs";
|
|
|
|
// 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, 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) => !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 = buildMultiselectRequest({
|
|
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, 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 = 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.",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
export function createMemoryGate(dependencies) {
|
|
return {
|
|
install: (pi) => installMemoryGate(pi, dependencies),
|
|
reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies),
|
|
};
|
|
}
|