fix: clarify memory selection semantics

This commit is contained in:
User
2026-07-14 13:13:59 +02:00
parent 1e3c5a1e99
commit c7d586aaf7
10 changed files with 205 additions and 24 deletions
@@ -0,0 +1,18 @@
const test = require("node:test");
const assert = require("node:assert");
const { memorySelectionWidgetProps } = require("../../tht-gate.js");
test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () => {
assert.deepEqual(
memorySelectionWidgetProps([
{ id: "recommended", label: "Memory A", recommended: true },
{ id: "optional", label: "Memory B" },
]),
{
title: "Seleziona le memory da applicare alla domanda",
selected: ["recommended"],
selectionLabel: "memory da applicare",
confirmLabel: "Applica le memory selezionate",
},
);
});
@@ -7,6 +7,13 @@ const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");
test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tht-gate-no-pack-"));
const bin = path.join(tmp, "bin");
fs.mkdirSync(bin);
const tht = path.join(bin, "tht");
fs.writeFileSync(tht, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const oldPath = process.env.PATH;
process.env.PATH = bin + ":" + oldPath;
process.env.THT_SESSION = "2026-06-27-100000-test";
try {
const { pi, ctx } = createFakePi();
@@ -19,13 +26,15 @@ test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async
assert.doesNotMatch(text, /tht session new/);
assert.match(text, /retrieval pack non era ancora disponibile/i);
assert.match(text, /tht search pack/);
assert.doesNotMatch(text, /<retrieval-pack>/);
assert.doesNotMatch(text, /<retrieval-pack>\s*# Retrieval pack/);
assert.match(text, /<tht-sessione-skill>/);
assert.match(text, /# Thoth session workflow \(phases 1-8\)/);
assert.match(text, /non esplorare il repository/i);
assert.doesNotMatch(text, /Carica la skill leggendo/);
} finally {
process.env.PATH = oldPath;
delete process.env.THT_SESSION;
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+4 -1
View File
@@ -61,6 +61,7 @@ function buildSelectRequest({
// `allowEmpty:false` with zero options is a broken widget and throws.
function buildMultiselectRequest({
id, phase, title, options, content = null, selected = [], allowEmpty = false,
selectionLabel, confirmLabel,
}) {
requireString(title, "title", "multiselect");
const opts = requireArray(options, "options", "multiselect");
@@ -77,10 +78,12 @@ function buildMultiselectRequest({
widget: "multiselect",
title,
allow_empty: allowEmpty,
options: [...opts],
options: opts.map((option) => ({ ...option, selected: selected.includes(option.id) })),
selected: [...selected],
content,
reserved: RESERVED,
...(selectionLabel ? { selection_label: selectionLabel } : {}),
...(confirmLabel ? { confirm_label: confirmLabel } : {}),
};
}
+13 -2
View File
@@ -386,6 +386,17 @@ 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",
};
}
// --- 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.
@@ -730,10 +741,10 @@ export default function (pi) {
const widget = buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
title,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
recommended: opts.find((o) => o.recommended)?.id ?? null,
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
});
const resp = await emitAndWait(ctx, widget);
if (resp.control === "freetext") {
+10 -10
View File
@@ -215,21 +215,21 @@ Prerequisite: Phase 1 closed.
allow_empty:true)`. Rules: at most **5** candidates; ONLY the 3 reusable types
(`concept_clarified`, `table_promoted`, `table_excluded`) — query-specific
decisions (`question_rewritten`, `sql_approved`, …) are NOT transferable, never
propose them. Each option carries `mem_id:"mem-<id>"` plus `type`/`subject`/
`rationale`. Selected options are applied (register the decision citing the
`mem_id` in the rationale); deselected ones are recorded as `memory_rejected` by
the gate (so the next `tht memory search --session` won't re-propose them). The
checklist starts pre-selected with the recommended memories. With `allow_empty:true`
an empty selection is accepted (no memory applied; deselected still recorded) and
the phase advances — no separate gate.
propose them. Each option carries `type`/`subject`/`rationale`; cite the source
memory id (`mem-<id>`) in its rationale when applying it. Every option describes a
candidate memory; never create an opposite "do not use" option. Only
`recommended:true` options start checked. A
deselected candidate is **not applied now**, not rejected, and may be considered
again if Phase 2 is reopened. With `allow_empty:true` an empty selection is accepted
(no memory applied) and the phase advances — no separate gate.
When the memory search returned **zero** candidates, still issue the single
`reviewer_decide(multi:true, advance:true, allow_empty:true)` with an empty merito list: the gate
detects the empty+advance case, shows the reviewer an info notice ("Nessuna memory
riutilizzabile … passo alla fase successiva") and auto-advances F2 — it does NOT present
an empty checklist, and you do NOT add a separate `reviewer_confirm kind:"phase"`.
4. Closing: if memories were applied or rejected (substantive decisions), `advance:true`
no-ops — close with `reviewer_confirm kind:"phase"`. Only a truly empty memory phase
(nothing applied, nothing rejected) auto-advances via `advance:true`.
4. Closing: if one or more memories were applied (substantive decisions), `advance:true`
no-ops — close with `reviewer_confirm kind:"phase"`. If none is applied, F2
auto-advances via `advance:true`.
5. Memories are promoted at the END of the workflow (Phase 8, the
`reviewer_memory_promote` gate) — never promote from here, never run
`tht memory promote`/`save-one` yourself (the gate blocks them).
+7 -8
View File
@@ -24,14 +24,13 @@ Rules:
(e.g. `question_rewritten`, `sql_approved`) are NOT to be proposed: they don't
transfer to other questions.
- All candidate memories go in **a single** `reviewer_decide(multi:true,
advance:true, allow_empty:true)`: each selected option is applied (register the
decision with the appropriate type, citing the memory id in the rationale), each
deselected option is **recorded as `memory_rejected` by the gate** (so the next
`tht memory search --session` won't re-propose it). To enable this, EVERY memory
option MUST carry the field `mem_id:"mem-<id>"` (besides `type`/`subject`/
`rationale`). The checklist starts pre-selected with the recommended memories.
With `allow_empty:true` an **empty selection is accepted** (no memory applied; the
deselected ones are still recorded as rejected) and the phase advances — no
advance:true, allow_empty:true)`: every option describes a candidate memory, never
an opposite action such as "do not use it". Each selected option is applied
(register the decision with the appropriate type, citing the memory id in the
rationale). Mark `recommended:true` ONLY on memories proposed for use: those, and
only those, start checked. An unchecked memory is **not applied now**; it is not a
rejection and may be considered again if Phase 2 is reopened. With `allow_empty:true`
an **empty selection is accepted** (no memory applied) and the phase advances — no
separate gate.
- For "inspect": show the memory's full JSON record in the prose before presenting
the checklist, if the reviewer asks.