Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
230 lines
9.1 KiB
JavaScript
230 lines
9.1 KiB
JavaScript
import { Type } from "typebox";
|
|
import { installRepairGate } from "./repair.js";
|
|
|
|
// 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-(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\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.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult }) {
|
|
pi.registerTool({
|
|
name: "reviewer_memory_promote",
|
|
label: "Review Memory summary",
|
|
description: "F8: show the editable Memory summary from persisted proposals and approved " +
|
|
"decisions. The reviewer selects additions, updates and links. Only those choices " +
|
|
"are saved. Then close F8 and finalize. Prepare reusable rules during the workflow " +
|
|
"with tht memory propose; do not call review-apply yourself.",
|
|
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);
|
|
if (phase.number > 8) return workflow.close(ctx, session, phase.number, "Memory reviewed.");
|
|
if (phase.number !== 8) return toTextResult("Review the Memory summary at the end of F8.");
|
|
const summary = JSON.parse(memory.execute(ctx, ["summary", "--session", session, "--json"]));
|
|
if (summary.reviewed) {
|
|
const failure = ledger.record(ctx, session, {
|
|
type: "memory_summary_reviewed", subject: summary.summary_id,
|
|
detail: "Previously saved Memory review recovered.",
|
|
}, "Retry to record the recovered Memory review.");
|
|
if (failure) return failure;
|
|
return workflow.close(ctx, session, phase.number, "Memory review recovered.");
|
|
}
|
|
const response = await waitForReviewer(ctx, {
|
|
id: `u${Date.now()}`, schema_version: 1, phase: phase.id,
|
|
widget: "memory-review", title: "Memory for future questions",
|
|
summary, reserved: ["other", "back", "exit"],
|
|
});
|
|
if (response.control) return toTextResult(
|
|
response.control === "freetext"
|
|
? `Reviewer feedback: ${response.text}. Revise the proposals and present the summary again.`
|
|
: `Reviewer requested: ${response.control}.`);
|
|
const selection = JSON.parse(response.text || "{}");
|
|
if (selection.summary_id !== summary.summary_id || !Array.isArray(selection.items))
|
|
return toTextResult("Invalid Memory review response. Present the summary again.");
|
|
const failure = await memory.mutate(ctx, [
|
|
"review-apply", "--session", session, "--review-json", JSON.stringify(selection), "--json",
|
|
], "The Memory review is recoverable. Present the summary again if its content changed.");
|
|
if (failure) return failure;
|
|
const selected = new Set(selection.items.map(item => item.id));
|
|
const ledgerFailure = ledger.record(ctx, session, {
|
|
type: "memory_summary_reviewed", subject: summary.summary_id,
|
|
detail: `Saved ${selected.size} cards; declined ${summary.items.length-selected.size}.`,
|
|
}, "Memory is saved. Retry to record the review and finalize.");
|
|
if (ledgerFailure) return ledgerFailure;
|
|
const message = `Memory reviewed: ${selected.size} cards saved, ${summary.items.length-selected.size} declined.`;
|
|
return workflow.close(ctx, session, phase.number, message) || toTextResult(message);
|
|
} catch (error) {
|
|
return toTextResult(`[reviewer_memory_promote] ${String(error.stderr || error.message || error).trim()}`);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
export function createMemoryGate(dependencies) {
|
|
return {
|
|
install: (pi) => {
|
|
installMemoryGate(pi, dependencies);
|
|
installRepairGate(pi, dependencies);
|
|
},
|
|
reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies),
|
|
};
|
|
}
|