refactor(memory): extract F2 recall path (#23)

This commit is contained in:
2026-08-24 01:08:15 +02:00
parent beac2e80f4
commit 93fe0d733b
8 changed files with 585 additions and 236 deletions
+32 -111
View File
@@ -44,7 +44,7 @@ import {
enrichPhaseSummaryV2,
appendLedgerSection,
} from "./gate/enrich.js";
import { installMemoryGate } from "./gate/memory/index.js";
import { createMemoryGate } 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
@@ -554,76 +554,6 @@ export function isJoinReviewApproval(resp, optionIds) {
[...chosen].every((id) => expected.has(id));
}
// True when a reviewer_decide has no merito options but is allowed to close empty
// and advance (the empty memory phase F2). The gate then shows an info notice and
// auto-advances instead of presenting an empty checklist.
export function shouldSkipEmptyDecide({ meritCount, allowEmpty, advance }) {
return meritCount === 0 && !!allowEmpty && !!advance;
}
// Phase 2 uses a single positive checkbox meaning: apply this memory now.
// Candidates not checked by the reviewer remain undecided and may be shown again.
export 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",
};
}
const MEMORY_ID_RE = /\bmem-\d{4,}\b/i;
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
}
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(":");
}
// F2 is model-orchestrated, so normalize at the trust boundary: retain the exact
// retrieved memory text for the reviewer and collapse repeated representations of
// the same memory id/content before rendering or persisting a choice.
export 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;
}
export async function emitAndWait(ctx, descriptor) {
for (;;) {
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
@@ -662,6 +592,33 @@ export default function (pi) {
let lastSteered = false;
let pendingKickoff = null;
let activeSessionId = null;
const memoryGate = createMemoryGate({
workflow: {
activate: () => {
lockActive = true;
},
phase: (ctx, session) => {
const number = currentPhase(ctx, session);
return { number, id: phaseId(ctx, number) };
},
advance: advanceIfReady,
close: (...args) => closeAfterPromotion(...args),
},
memory: {
execute: (ctx, args) => tht(ctx, ["memory", ...args]),
mutate: (ctx, args, recovery) => relayIfThtFails(
ctx, ["memory", ...args], recovery,
),
},
ledger: {
validate: validateDecisionTypes,
record: (ctx, session, decision, recovery) => relayIfThtFails(
ctx, decisionAddArgs(session, decision), recovery,
),
},
waitForReviewer: emitAndWait,
toTextResult: textResult,
});
// 1) ANTI-BYPASS tool_call hook (spec D4, verbatim). Blocks direct phase/decision
// calls and writes to protected files so the reviewer cannot bypass the gate.
@@ -1014,9 +971,8 @@ export default function (pi) {
try {
const { session, title, advance } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
const opts = phase === "F2"
? normalizeMemoryOptions(params.options)
: params.options;
if (phase === "F2") return await memoryGate.reviewRecall(ctx, params, phase);
const opts = params.options;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const toAdd = [];
@@ -1032,16 +988,6 @@ export default function (pi) {
const joinOnly = meritOptions.length > 0 && opts
.filter((o) => !isReserved(o.label))
.every((o) => o.decision.type === "join_modified");
if (shouldSkipEmptyDecide({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance })) {
await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
"info",
);
advanceIfReady(ctx, session);
return textResult(
"Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva.",
);
}
const widget = joinOnly
? buildJoinReviewRequest({
id: `u${Date.now()}`,
@@ -1059,10 +1005,9 @@ export default function (pi) {
: buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
title,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
});
let resp;
for (;;) {
@@ -1634,31 +1579,7 @@ export default function (pi) {
);
}
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,
});
memoryGate.install(pi);
pi.registerTool({
name: "rewrite_question",