refactor(memory): extract F8 promotion gate (#22)
This commit is contained in:
@@ -18,10 +18,8 @@
|
||||
// auto-advance not ready -> silent no-op)
|
||||
// - textResult / tht() / relayIfThtFails / advanceIfReady helpers
|
||||
//
|
||||
// TESTING: the pure builders are L1-tested (./gate/__tests__/). This file is the
|
||||
// GLUE -- it depends on the Pi runtime (pi.on, pi.registerTool, ctx.sendRaw) and is
|
||||
// verified end-to-end at L2 (Task D4). It is intentionally NOT unit-tested here; a
|
||||
// fake-Pi runtime mock (cross-cutting follow-up) would let it run in CI.
|
||||
// TESTING: pure builders and domain modules are L1-tested under ./gate/__tests__/.
|
||||
// The composition root is exercised through the fake Pi runtime and live at L2.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -46,6 +44,7 @@ import {
|
||||
enrichPhaseSummaryV2,
|
||||
appendLedgerSection,
|
||||
} from "./gate/enrich.js";
|
||||
import { installMemoryGate } from "./gate/memory/index.js";
|
||||
import { isReserved } from "./reserved-labels.mjs";
|
||||
|
||||
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
||||
@@ -625,58 +624,6 @@ export function normalizeMemoryOptions(options) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------
|
||||
// The candidates come from `tht memory promote --preview --json` (deterministic,
|
||||
// reviewer-approved decisions only); the model never authors them.
|
||||
export 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;
|
||||
});
|
||||
}
|
||||
|
||||
export function promotionOptions(candidates) {
|
||||
return candidates.map((c) => ({
|
||||
id: `seq-${c.decision_seq}`,
|
||||
label: `${c.type}: ${c.subject}`,
|
||||
detail: c.detail || "",
|
||||
rationale: c.rationale || "",
|
||||
meta: { question_context: c.question_context || "" },
|
||||
}));
|
||||
}
|
||||
|
||||
export function promotionContent(candidates) {
|
||||
return candidates
|
||||
.map(
|
||||
(c) =>
|
||||
`- **${c.type}: ${c.subject}** (decisione #${c.decision_seq})\n` +
|
||||
` ${c.detail || ""}\n` +
|
||||
` Motivo: ${c.rationale || "—"}\n` +
|
||||
` Domanda di contesto: ${c.question_context || "—"}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitPromotionChoices(candidates, choices) {
|
||||
const chosen = new Set(choices ?? []);
|
||||
const promote = [];
|
||||
const decline = [];
|
||||
for (const c of candidates) {
|
||||
(chosen.has(`seq-${c.decision_seq}`) ? promote : decline).push(c);
|
||||
}
|
||||
return { promote, decline };
|
||||
}
|
||||
|
||||
export async function emitAndWait(ctx, descriptor) {
|
||||
for (;;) {
|
||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||
@@ -1687,114 +1634,30 @@ export default function (pi) {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
lockActive = true;
|
||||
try {
|
||||
const { session } = params;
|
||||
const curNum = currentPhase(ctx, session);
|
||||
const phase = phaseId(ctx, curNum);
|
||||
let candidates;
|
||||
try {
|
||||
candidates = JSON.parse(
|
||||
tht(ctx, ["memory", "promote", "--session", session, "--preview", "--json"]),
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
||||
return textResult(`Preview di promozione non disponibile: ${msg}`);
|
||||
}
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
await ctx.ui.notify(
|
||||
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
|
||||
"info",
|
||||
);
|
||||
const closed = closeAfterPromotion(
|
||||
ctx, session, curNum, "Nessun candidato di promozione.",
|
||||
);
|
||||
if (closed) return closed;
|
||||
return textResult(
|
||||
"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 = closeAfterPromotion(
|
||||
ctx, session, curNum, "Nessun candidato di promozione.",
|
||||
);
|
||||
if (closed) return closed;
|
||||
return textResult(
|
||||
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
|
||||
);
|
||||
}
|
||||
const options = promotionOptions(candidates);
|
||||
const widget = buildMultiselectRequest({
|
||||
id: `u${Date.now()}`,
|
||||
phase,
|
||||
title: "Quali concetti chiariti salvare nella memoria riutilizzabile?",
|
||||
allowEmpty: true,
|
||||
options,
|
||||
selected: options.map((o) => o.id),
|
||||
content: promotionContent(candidates),
|
||||
});
|
||||
const resp = await emitAndWait(ctx, widget);
|
||||
if (resp.control === "freetext")
|
||||
return textResult(`Altro (reviewer): ${resp.text}. Valuta e ripresenta il gate.`);
|
||||
if (resp.control === "back")
|
||||
return textResult("Il reviewer vuole tornare indietro.");
|
||||
if (resp.control === "exit") return textResult("Il reviewer vuole uscire.");
|
||||
const { promote, decline } = splitPromotionChoices(candidates, resp.choices);
|
||||
let saved = 0;
|
||||
for (const c of promote) {
|
||||
const err = relayIfThtFails(
|
||||
ctx,
|
||||
["memory", "save-one", "--session", session,
|
||||
"--decision", String(c.decision_seq), "--json"],
|
||||
`Recupero manuale (umano): tht memory save-one --session ${session} --decision ${c.decision_seq}. Finora salvate: ${saved}.`,
|
||||
);
|
||||
if (err) return err;
|
||||
const e2 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "memory_promoted", subject: c.subject,
|
||||
detail: `seq:${c.decision_seq}`, rationale: c.rationale || c.detail || "",
|
||||
}), `Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: recupero manuale (umano) con tht decision add --session ${session} --type memory_promoted --subject "${c.subject}" --detail seq:${c.decision_seq}.`);
|
||||
if (e2) return e2;
|
||||
saved++;
|
||||
}
|
||||
for (const c of decline) {
|
||||
const err = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "memory_promotion_declined", subject: c.subject,
|
||||
detail: `seq:${c.decision_seq}`,
|
||||
}), "");
|
||||
if (err) return err;
|
||||
}
|
||||
const summary =
|
||||
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
|
||||
`${decline.length} candidati scartati.`;
|
||||
const closed = closeAfterPromotion(ctx, session, curNum, summary);
|
||||
if (closed) return closed;
|
||||
return textResult(summary);
|
||||
} catch (fatal) {
|
||||
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
||||
return textResult(`[reviewer_memory_promote ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);
|
||||
}
|
||||
installMemoryGate(pi, {
|
||||
workflow: {
|
||||
activate: () => {
|
||||
lockActive = true;
|
||||
},
|
||||
phase: (ctx, session) => {
|
||||
const number = currentPhase(ctx, session);
|
||||
return { number, id: phaseId(ctx, number) };
|
||||
},
|
||||
close: closeAfterPromotion,
|
||||
},
|
||||
memory: {
|
||||
execute: (ctx, args) => tht(ctx, ["memory", ...args]),
|
||||
mutate: (ctx, args, recovery) => relayIfThtFails(
|
||||
ctx, ["memory", ...args], recovery,
|
||||
),
|
||||
},
|
||||
ledger: {
|
||||
record: (ctx, session, decision, recovery) => relayIfThtFails(
|
||||
ctx, decisionAddArgs(session, decision), recovery,
|
||||
),
|
||||
},
|
||||
waitForReviewer: emitAndWait,
|
||||
toTextResult: textResult,
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user