diff --git a/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js index 4add0105..08f5c2e4 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js @@ -1,67 +1,156 @@ const test = require("node:test"); const assert = require("node:assert"); -const { - dedupePromotionCandidates, - promotionOptions, - promotionContent, - splitPromotionChoices, -} = require("../../tht-gate.js"); +const { installMemoryGate } = require("../memory/index.js"); +const { createFakePi } = require("./fake_pi_runtime.js"); -// F8 memory-promotion gate: the candidates are DETERMINISTIC (computed by -// `tht memory promote --preview --json`), the model only names the session. -// These tests pin the pure candidate->widget mapping and the choice partition. -const CANDIDATES = [ - { decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione", - detail: "tabella principale ablazioni", rationale: "scelta dal reviewer", - question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] }, - { decision_seq: 5, type: "concept_clarified", subject: "paziente attivo", - detail: "flag_attivo = TRUE", rationale: "", - question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] }, -]; +const TABLE_CANDIDATE = { + decision_seq: 3, + type: "table_promoted", + subject: "fact_seeablazione", + detail: "tabella principale ablazioni", + rationale: "scelta dal reviewer", + question_context: "quante ablazioni nel 2023", + tables: ["fact_seeablazione"], + concepts: [], +}; -test("promotionOptions maps candidates to seq-keyed options", () => { - assert.deepEqual(promotionOptions(dedupePromotionCandidates(CANDIDATES)), [ +const MEMORY_CANDIDATE = { + decision_seq: 5, + type: "concept_clarified", + subject: "paziente attivo", + detail: "flag_attivo = TRUE", + rationale: "scelta dal reviewer", + question_context: "quante ablazioni nel 2023", + tables: [], + concepts: ["paziente attivo"], +}; + + +test("the public Memory facade owns F8 policy and mutation ordering", async () => { + const { pi, tools, ctx } = createFakePi(); + const calls = []; + let descriptor; + const duplicateMemory = { ...MEMORY_CANDIDATE, decision_seq: 6 }; + const declinedMemory = { + ...MEMORY_CANDIDATE, + decision_seq: 7, + subject: "ricovero indice", + detail: "first_event", + }; + ctx.ui.input = async (title) => { + descriptor = JSON.parse(title); + calls.push(["review", descriptor.options.map((option) => option.id)]); + return JSON.stringify({ id: descriptor.id, choices: ["seq-5"] }); + }; + + installMemoryGate(pi, { + workflow: { + activate: () => calls.push(["activate"]), + phase: () => ({ number: 8, id: "F8" }), + close: (_ctx, _session, phaseNumber, summary) => { + calls.push(["close", phaseNumber, summary]); + return null; + }, + }, + memory: { + execute: (_ctx, args) => { + calls.push(["memory-execute", args]); + return JSON.stringify([ + TABLE_CANDIDATE, + MEMORY_CANDIDATE, + duplicateMemory, + declinedMemory, + ]); + }, + mutate: (_ctx, args, recovery) => { + calls.push(["memory-mutate", args, recovery]); + return null; + }, + }, + ledger: { + record: (_ctx, session, decision, recovery) => { + calls.push(["ledger", session, decision, recovery]); + return null; + }, + }, + waitForReviewer: async (runtimeContext, widget) => { + const response = await runtimeContext.ui.input(JSON.stringify(widget), ""); + return JSON.parse(response); + }, + toTextResult: (text) => ({ content: [{ type: "text", text }] }), + }); + + const result = await tools.get("reviewer_memory_promote").def.execute( + "promote-via-facade", + { session: "s1" }, + null, + null, + ctx, + ); + + assert.deepEqual(descriptor.options, [ { id: "seq-5", label: "concept_clarified: paziente attivo", detail: "flag_attivo = TRUE", - rationale: "", + rationale: "scelta dal reviewer", meta: { question_context: "quante ablazioni nel 2023" }, + selected: true, + }, + { + id: "seq-7", + label: "concept_clarified: ricovero indice", + detail: "first_event", + rationale: "scelta dal reviewer", + meta: { question_context: "quante ablazioni nel 2023" }, + selected: true, }, ]); -}); - -test("F8 proposes semantically identical memory content only once", () => { - const duplicate = { ...CANDIDATES[0], decision_seq: 9 }; - assert.deepEqual( - dedupePromotionCandidates([CANDIDATES[0], duplicate, CANDIDATES[1]]) - .map((candidate) => candidate.decision_seq), - [5], - ); -}); - -test("F8 never proposes promoted tables as memory", () => { - const candidates = dedupePromotionCandidates(CANDIDATES); - const options = promotionOptions(candidates); - const c = promotionContent(candidates); - - assert.deepEqual(candidates.map((candidate) => candidate.type), ["concept_clarified"]); - assert.deepEqual(options.map((option) => option.id), ["seq-5"]); - assert.ok(!c.includes("fact_seeablazione")); - assert.ok(c.includes("flag_attivo = TRUE")); - assert.ok(c.includes("quante ablazioni nel 2023")); -}); - -test("splitPromotionChoices partitions by selection", () => { - const candidates = dedupePromotionCandidates(CANDIDATES); - const { promote, decline } = splitPromotionChoices(candidates, ["seq-5"]); - assert.deepEqual(promote.map((c) => c.decision_seq), [5]); - assert.deepEqual(decline.map((c) => c.decision_seq), []); -}); - -test("empty or missing choices declines everything", () => { - const candidates = dedupePromotionCandidates(CANDIDATES); - assert.equal(splitPromotionChoices(candidates, []).decline.length, 1); - assert.equal(splitPromotionChoices(candidates, undefined).decline.length, 1); + assert.deepEqual(descriptor.selected, ["seq-5", "seq-7"]); + assert.doesNotMatch(descriptor.content, /fact_seeablazione/); + assert.match(descriptor.content, /flag_attivo = TRUE/); + assert.deepEqual(calls.slice(0, 7), [ + ["activate"], + [ + "memory-execute", + ["promote", "--session", "s1", "--preview", "--json"], + ], + ["review", ["seq-5", "seq-7"]], + [ + "memory-mutate", + ["save-one", "--session", "s1", "--decision", "5", "--json"], + "Recupero manuale (umano): tht memory save-one --session s1 " + + "--decision 5. Finora salvate: 0.", + ], + [ + "ledger", + "s1", + { + type: "memory_promoted", + subject: "paziente attivo", + detail: "seq:5", + rationale: "scelta dal reviewer", + }, + "Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: " + + "recupero manuale (umano) con tht decision add --session s1 " + + "--type memory_promoted --subject \"paziente attivo\" --detail seq:5.", + ], + [ + "ledger", + "s1", + { + type: "memory_promotion_declined", + subject: "ricovero indice", + detail: "seq:7", + }, + "", + ], + [ + "close", + 8, + "Promozione registrata: 1 memorie salvate nel vectordb, 1 candidati scartati.", + ], + ]); + assert.match(result.content[0].text, /1 memorie salvate.*1 candidati scartati/); }); diff --git a/harness/.pi/extensions/gate/memory/index.js b/harness/.pi/extensions/gate/memory/index.js new file mode 100644 index 00000000..a5d9dfb1 --- /dev/null +++ b/harness/.pi/extensions/gate/memory/index.js @@ -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.", + ); + } + }, + }); +} diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 6b3be7f1..25c5f9c5 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -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({