Files
ThothII/harness/.pi/extensions/gate/memory/index.js
T

368 lines
12 KiB
JavaScript

import { Type } from "typebox";
// 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, reviewer, 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) => !reviewer.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 = reviewer.buildMultiselect({
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, reviewer, 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 = reviewer.buildMultiselect({
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),
};
}