refactor(memory): extract F2 recall path (#23)
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user