Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { installRepairGate } = require("../memory/repair.js");
const { gateText } = require("../core/locale.js");
function gate({ answers, fail = false, resume = false }) {
let tool;
@@ -8,6 +9,8 @@ function gate({ answers, fail = false, resume = false }) {
const repair = { repair_id: "receipt", choice: null, status: "proposed", can_apply: true,
saved: false, indexed: false, options: [{ id: "fix", content: { detail: "Correction" } }] };
installRepairGate({ registerTool: def => { tool = def; } }, {
languageFor: () => "en",
gateText,
workflow: { activate() {}, phase: () => ({ id: "F4" }) },
memory: { execute: (_ctx, args) => {
calls.push(args);
@@ -208,8 +208,8 @@ test("buildArtifactGate derives approve/reject options from action.kind", () =>
action: { kind: "approve_reject" },
});
assert.deepEqual(w.options, [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed", recommended: true },
{ id: "reject", label: "Reject", label_i18n: "Reject" },
]);
});
@@ -217,7 +217,7 @@ test("buildArtifactGate: confirm -> single approve; view_only -> no options", ()
const confirm = buildArtifactGate({
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "confirm" },
});
assert.deepEqual(confirm.options, [{ id: "approve", label: "Salva e procedi", recommended: true }]);
assert.deepEqual(confirm.options, [{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed", recommended: true }]);
const viewOnly = buildArtifactGate({
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "view_only" },
});
@@ -214,7 +214,7 @@ test("appendLedgerSection appends only the phase's substantive decisions", () =>
const out = appendLedgerSection(data, decisions, ["concept_clarified", "ambiguity_open"]);
assert.equal(out.sections.length, 2);
const ledger = out.sections[1];
assert.equal(ledger.title, "Decisioni registrate in questa fase (dal ledger)");
assert.equal(ledger.title, "Decisions recorded in this phase (from the ledger)");
assert.equal(ledger.items.length, 1);
assert.equal(ledger.items[0].label, "concept_clarified: ablazione");
assert.equal(ledger.items[0].value, "solo transcatetere");
@@ -18,7 +18,9 @@ if (typeof globalThis.require === "undefined") {
}
const shell = { current: () => "" };
cp.execFileSync = (file, args, opts) => shell.current(file, args, opts);
cp.execFileSync = (file, args, opts) => args[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: shell.current(file, args, opts);
const META = JSON.stringify({
max_phase: 8,
@@ -23,7 +23,9 @@ if (typeof globalThis.require === "undefined") {
// swap `activeStub` instead of clobbering the dispatcher. Each test's existing
// `cp.execFileSync = fn` / restore-in-finally then Just Works.
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
const dispatcher = (...args) => args[1]?.[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true,
get: () => dispatcher,
@@ -30,6 +30,8 @@ async function loadGate() {
function phase8Stub(calls) {
return (_file, args) => {
calls.push(args);
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "it", workspace_language: "it" });
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({
max_phase: 8,
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const { createFakePi } = require("./fake_pi_runtime.js");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (!globalThis.require) globalThis.require = createRequire(GATE);
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true, get: () => dispatcher, set: fn => { activeStub = fn; },
});
for (const [language, workspaceLanguage, entry, title, yes, no] of [
["en", "it", "/riprendi-sessione s1", "Generate a datamart?", "Yes, generate the datamart", "No, skip the datamart"],
["it", "en", '/nuova-domanda "Pazienti"', "Vuoi generare un datamart?", "Sì, genera il datamart", "No, salta il datamart"],
["fr-FR", "it", "/riprendi-sessione s1", "Generate a datamart?", "Yes, generate the datamart", "No, skip the datamart"],
]) test(`manifest language ${language} governs every model turn and generated gate chrome`, async () => {
const previous = { exec: cp.execFileSync, session: process.env.THT_SESSION,
language: process.env.THT_INTERACTION_LANGUAGE, profile: process.env.THT_PROFILE };
process.env.THT_SESSION = "s1";
process.env.THT_INTERACTION_LANGUAGE = workspaceLanguage; // stale launch hints have no authority
process.env.THT_PROFILE = "server";
cp.execFileSync = (_file, args) => {
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ id: "s1", interaction_language: language, workspace_language: workspaceLanguage });
if (args[0] === "session" && args[1] === "retrieval-pack") return "Pazienti: età > 65";
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ max_phase: 8, phases: [{ num: 8, id: "F8", name: "datamart",
emits: ["datamart_requested", "datamart_declined"] }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n";
return "";
};
try {
const { default: installGate } = await import(GATE);
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-language-test";
installGate(pi);
await pi.emit("input", { text: entry, source: "rpc" });
for (let turn = 0; turn < 2; turn++) {
const injected = await pi.emit("before_agent_start", { systemPrompt: "BASE" });
assert.ok(injected.systemPrompt.includes(`interaction_language=${language}`));
assert.ok(injected.systemPrompt.includes(`workspace_language=${workspaceLanguage}`));
assert.match(injected.systemPrompt, /reviewer questions, explanations, and choices/);
}
let widget;
ctx.ui.input = async title => {
widget = JSON.parse(title);
return JSON.stringify({ id: widget.id, choices: ["skip"] });
};
await tools.get("reviewer_datamart").def.execute("call", { session: "s1" }, null, null, ctx);
assert.equal(widget.title, title);
assert.equal(widget.title_i18n, "Generate a datamart?");
assert.deepEqual(widget.options.map(o => [o.id, o.label]), [
["generate", yes], ["skip", no],
]);
} finally {
cp.execFileSync = previous.exec;
for (const [key, value] of [["THT_SESSION", previous.session],
["THT_INTERACTION_LANGUAGE", previous.language], ["THT_PROFILE", previous.profile]]) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
});
test("an unavailable manifest blocks model startup instead of trusting a launch hint", async () => {
const originalExec = cp.execFileSync;
const previousSession = process.env.THT_SESSION;
process.env.THT_SESSION = "unavailable";
cp.execFileSync = () => { throw new Error("storage unavailable"); };
try {
const { default: installGate } = await import(GATE);
const { pi, ctx } = createFakePi();
ctx.cwd = "/nonexistent-language-test";
installGate(pi);
await pi.emit("input", { text: "/riprendi-sessione unavailable", source: "rpc" });
await assert.rejects(pi.emit("before_agent_start", { systemPrompt: "BASE" }), /storage unavailable/);
} finally {
cp.execFileSync = originalExec;
if (previousSession === undefined) delete process.env.THT_SESSION;
else process.env.THT_SESSION = previousSession;
}
});
test("artifact and join chrome localize while SQL, decisions and generated text remain verbatim", () => {
const { buildArtifactGate, buildJoinReviewRequest } = require("../core/builders.js");
const artifact = { kind: "sql", data: "SELECT nome FROM pazienti WHERE stato = 'attivo'" };
const options = [{ id: "join-1", label: "Collega pazienti", detail: "p.id = v.id" }];
for (const [locale, save, reject, next] of [
["en", "Save and proceed", "Reject", "Continue"],
["it", "Salva e procedi", "Rifiuta", "Continua"],
["fr", "Save and proceed", "Reject", "Continue"],
]) {
const gate = buildArtifactGate({ id: "u1", phase: "F7", title: "SQL finale",
artifact, action: { kind: "approve_reject" }, locale });
assert.deepEqual(gate.options.map(o => [o.id, o.label]), [["approve", save], ["reject", reject]]);
assert.deepEqual(gate.artifact, artifact);
assert.equal(gate.title, "SQL finale");
const joins = buildJoinReviewRequest({ id: "u2", phase: "F4", title: "Join", options, locale });
assert.equal(joins.confirm_label, next);
assert.deepEqual(joins.options, options);
}
});
@@ -36,6 +36,8 @@ const JOIN_OPTIONS = [
function shellStub(calls, inputs = []) {
return (_file, args, options = {}) => {
calls.push(args.join(" "));
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "en", workspace_language: "it" });
if (options.input) inputs.push(options.input);
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createMemoryGate } = require("../memory/index.js");
const { gateText } = require("../core/locale.js");
const { createFakePi } = require("./fake_pi_runtime.js");
function setup({ phase = 8, response, failure = null } = {}) {
@@ -8,6 +9,8 @@ function setup({ phase = 8, response, failure = null } = {}) {
const calls = [];
const summary = { summary_id: "summary", items: [{ id: "rule", card: { subject: "Rule" } }] };
createMemoryGate({
languageFor: () => "en",
gateText,
workflow: { activate() {}, phase: () => ({ number: phase, id: "F" + phase }),
close: () => { calls.push("close"); return "closed"; } },
memory: { execute: () => JSON.stringify(summary),
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert");
const { createMemoryGate } = require("../memory/index.js");
const { gateText } = require("../core/locale.js");
const { buildMultiselectRequest } = require("../core/builders.js");
const { isReserved } = require("../core/reserved-labels.mjs");
const { createFakePi } = require("./fake_pi_runtime.js");
@@ -43,7 +44,7 @@ const MEMORY_OPTIONS = [
];
function setupRecall(choices) {
function setupRecall(choices, language = "it") {
const { pi, tools, ctx } = createFakePi();
const calls = [];
let descriptor;
@@ -53,6 +54,8 @@ function setupRecall(choices) {
return JSON.stringify({ id: descriptor.id, choices });
};
const memoryGate = createMemoryGate({
languageFor: () => language,
gateText,
workflow: {
activate: () => calls.push(["activate"]),
phase: () => ({ number: 8, id: "F8" }),
@@ -117,6 +120,19 @@ test("the Memory facade normalizes search hits and applies only the selected F2
assert.match(result.content[0].text, /1 decisioni.*La fase resta aperta/s);
});
test("English Memory chrome preserves Italian content and carries UI translation keys", async () => {
const { ctx, memoryGate, descriptor } = setupRecall(["first"], "en");
await memoryGate.reviewRecall(ctx, {
session: "s1", title: "Memory", options: MEMORY_OPTIONS, allow_empty: true, advance: false,
}, "F2");
assert.equal(descriptor().title, "Select memories to apply to the question");
assert.equal(descriptor().confirm_label, "Apply selected memories");
assert.equal(descriptor().confirm_label_i18n, "Apply selected memories");
assert.equal(descriptor().options[0].label, "Regola paziente attivo");
assert.equal(descriptor().options[0].detail, MEMORY_OPTIONS[0].description);
assert.equal(descriptor().options[0].label_i18n, undefined);
});
test("authoritative UUID card identities survive normalization into the reviewer widget", async () => {
const { ctx, memoryGate, descriptor } = setupRecall(["first"]);
const id = "mem-11111111-1111-4111-8111-111111111111";
@@ -26,7 +26,7 @@ case "$1 $2" in
"phase show") echo "Fase corrente: ${phase}";;
"phase meta") echo '{"max_phase":8,"phases":[{"num":2,"id":"F2","emits":[]},{"num":8,"id":"F8","emits":[]}]}';;
"memory summary") echo '{"summary_id":"saved-review","reviewed":true}';;
"session show") echo '{"status":"${status}"}';;
"session show"|"session ensure-interaction-language") echo '{"id":"s1","status":"${status}","interaction_language":"it","workspace_language":"it"}';;
*) echo "OK";;
esac
`;
@@ -11,12 +11,14 @@ test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async
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 });
fs.writeFileSync(tht, '#!/bin/sh\nif [ "$2" = "ensure-interaction-language" ]; then\n' +
'echo \'{"interaction_language":"it","workspace_language":"it"}\'\nelse\nexit 1\nfi\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();
ctx.cwd = tmp;
installGate(pi);
await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
const injected = await pi.emit("before_agent_start", { systemPrompt: "" });
@@ -56,7 +58,9 @@ test("con retrieval pack persistito lo inietta senza chiedere tool call ridondan
fs.writeFileSync(
tht,
"#!/bin/sh\n" +
"printf \"# Retrieval pack\\n\\nTABELLA_CANDIDATA\\n\"\n",
'if [ "$2" = "ensure-interaction-language" ]; then\n' +
'echo \'{"interaction_language":"it","workspace_language":"it"}\'\nelse\n' +
"printf \"# Retrieval pack\\n\\nTABELLA_CANDIDATA\\n\"\nfi\n",
{ mode: 0o755 },
);
const oldPath = process.env.PATH;
@@ -1,5 +1,15 @@
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { syncBuiltinESMExports } = require("node:module");
const originalExec = cp.execFileSync;
cp.execFileSync = (_file, args) => {
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "it", workspace_language: "it" });
throw new Error("No retrieval pack in this fixture");
};
syncBuiltinESMExports();
test.after(() => { cp.execFileSync = originalExec; syncBuiltinESMExports(); });
const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");
@@ -21,5 +21,5 @@ test("no-limbo: undefined (cancel) ri-presenta lo stesso widget", async () => {
const resp = await emitAndWait(ctx, descriptor);
assert.deepEqual(resp, { id: "u1", choices: ["a"] });
assert.equal(ctx.uiCalls.length, 2); // ri-presentato una volta
assert.ok(ctx.notifications.some((n) => /Esc non chiude/.test(n.message)));
assert.ok(ctx.notifications.some((n) => /Esc does not close/.test(n.message)));
});
@@ -10,7 +10,9 @@ const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
globalThis.require = createRequire(GATE);
const shell = { current: () => "" };
cp.execFileSync = (file, args, options) => shell.current(file, args, options);
cp.execFileSync = (file, args, options) => args[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: shell.current(file, args, options);
const installGate = require(GATE).default ?? require(GATE);
@@ -541,7 +543,7 @@ test("F8 warns about pending indexing but preserves the saved review and finaliz
const { ctx, tools, calls } = await setupGate({ phase: 8, preview: [PROMOTION_CANDIDATE], indexed: false });
answerReview(ctx, [PROMOTION_CANDIDATE]);
await tools.get("reviewer_memory_promote").def.execute("pending", { session: "s1" }, null, null, ctx);
assert(ctx.notifications.some(({ message, level }) => level === "warning" && message.includes("Memory saved")));
assert(ctx.notifications.some(({ message, level }) => level === "warning" && message.includes("Memory salvata")));
assert(memoryPromotionMutationArgs(calls).some(args => args.includes("memory_summary_reviewed")));
});
test("F8 recovers a saved review without asking again or saving cards again", async () => {
+14 -7
View File
@@ -14,6 +14,7 @@
const SCHEMA_VERSION = 1;
const RESERVED = ["back", "exit", "other"];
const { gateText } = require("./locale.js");
const VALID_ACTION_KINDS = new Set(["confirm", "approve_reject", "view_only"]);
const VALID_INFO_LEVELS = new Set(["info", "warning", "error"]);
@@ -38,6 +39,7 @@ function requireArray(value, name, ctx) {
// highlight as "(consigliato)".
function buildSelectRequest({
id, phase, title, options, intro = null, recommended = null,
titleI18n,
}) {
requireString(title, "title", "select");
const opts = requireArray(options, "options", "select");
@@ -48,6 +50,7 @@ function buildSelectRequest({
schema_version: SCHEMA_VERSION,
widget: "select",
title,
...(titleI18n ? { title_i18n: titleI18n } : {}),
intro,
recommended,
options: [...opts],
@@ -61,7 +64,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,
selectionLabel, confirmLabel, titleI18n, selectionLabelI18n, confirmLabelI18n,
}) {
requireString(title, "title", "multiselect");
const opts = requireArray(options, "options", "multiselect");
@@ -77,6 +80,7 @@ function buildMultiselectRequest({
schema_version: SCHEMA_VERSION,
widget: "multiselect",
title,
...(titleI18n ? { title_i18n: titleI18n } : {}),
allow_empty: allowEmpty,
options: opts.map((option) => ({ ...option, selected: selected.includes(option.id) })),
selected: [...selected],
@@ -84,13 +88,15 @@ function buildMultiselectRequest({
reserved: RESERVED,
...(selectionLabel ? { selection_label: selectionLabel } : {}),
...(confirmLabel ? { confirm_label: confirmLabel } : {}),
...(selectionLabelI18n ? { selection_label_i18n: selectionLabelI18n } : {}),
...(confirmLabelI18n ? { confirm_label_i18n: confirmLabelI18n } : {}),
};
}
// Build an `artifact-gate` ui_request: a rendered artifact + a disposition list
// (confirm / approve_reject / view_only). The decision is load-bearing on the
// document, so the artifact is never shown alone (spec §4.1).
function buildArtifactGate({ id, phase, title, artifact, action }) {
function buildArtifactGate({ id, phase, title, artifact, action, locale = "en" }) {
requireString(title, "title", "artifact-gate");
if (!artifact || typeof artifact !== "object") {
throw new Error("builders: artifact-gate requires an artifact object");
@@ -105,10 +111,10 @@ function buildArtifactGate({ id, phase, title, artifact, action }) {
}
const ACTION_OPTIONS = {
approve_reject: [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
{ id: "approve", label: gateText(locale, "save"), label_i18n: gateText("en", "save"), recommended: true },
{ id: "reject", label: gateText(locale, "reject"), label_i18n: gateText("en", "reject") },
],
confirm: [{ id: "approve", label: "Salva e procedi", recommended: true }],
confirm: [{ id: "approve", label: gateText(locale, "save"), label_i18n: gateText("en", "save"), recommended: true }],
view_only: [],
};
return {
@@ -180,7 +186,7 @@ function buildSchemaLinkingRequest({ id, phase, title, tables }) {
// Build a blocking, read-only join review. The reviewer can accept the complete
// proposal or use Other to request a textual correction; individual joins are
// deliberately not selectable because omitting one could create a Cartesian product.
function buildJoinReviewRequest({ id, phase, title, options }) {
function buildJoinReviewRequest({ id, phase, title, options, locale = "en" }) {
requireString(title, "title", "join-review");
const opts = requireArray(options, "options", "join-review");
if (opts.length === 0) {
@@ -194,7 +200,8 @@ function buildJoinReviewRequest({ id, phase, title, options }) {
widget: "join-review",
title,
options: [...opts],
confirm_label: "Continue",
confirm_label: gateText(locale, "continue"),
confirm_label_i18n: gateText("en", "continue"),
reserved: RESERVED,
};
}
+4 -2
View File
@@ -156,12 +156,14 @@ function enrichCteResultColumns(payload, planTables, getColumns) {
// comes from persisted state, not model prose). `decisions` is session-show's ledger
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
function appendLedgerSection(data, decisions, emits) {
function appendLedgerSection(data, decisions, emits, locale = "en") {
const { gateText } = require("./locale.js");
const emitSet = new Set(emits || []);
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
if (rows.length === 0) return data;
const section = {
title: "Decisioni registrate in questa fase (dal ledger)",
title: gateText(locale, "ledgerTitle"),
title_i18n: gateText("en", "ledgerTitle"),
items: rows.map((d) => ({
label: `${d.type}: ${d.subject}`,
value: d.detail || "",
@@ -0,0 +1,41 @@
// Only deterministic chrome is translated. Model prose and domain payloads are opaque.
const en = {
save: "Save and proceed", reject: "Reject", continue: "Continue",
approvalPrompt: "Approve or reject?", chooseApproval: "Choose Save and proceed or Reject.",
datamartTitle: "Generate a datamart?", datamartYes: "Yes, generate the datamart",
datamartNo: "No, skip the datamart",
memoryTitle: "Select memories to apply to the question", memorySelection: "memories to apply",
memoryApply: "Apply selected memories", memoryEmpty: "No reusable memories for this question; continuing to the next phase.",
memorySummary: "Memory for future questions", archiveRepair: "Resolve archive conflict",
repairError: "The correction could not complete. Review the current archive state before retrying.",
ledgerTitle: "Decisions recorded in this phase (from the ledger)",
escapeHelp: "Esc does not close the gate: use Back / Exit / Other.",
inputHelp: "Answer through the gate widgets. To send free text to the model, start the line with '!'.",
memoryIndexWarning: "Memory saved. Index update is incomplete; an administrator can retry it in Memory management.",
backUsage: "Usage: /torna <session_id> [N]",
backInvalid: "Invalid target (current phase {phase}).",
decision: "Decision",
};
const it = {
save: "Salva e procedi", reject: "Rifiuta", continue: "Continua",
approvalPrompt: "Approvi o rifiuti?", chooseApproval: "Scegli «Salva e procedi» o «Rifiuta».",
datamartTitle: "Vuoi generare un datamart?", datamartYes: "Sì, genera il datamart",
datamartNo: "No, salta il datamart",
memoryTitle: "Seleziona le memory da applicare alla domanda", memorySelection: "memory da applicare",
memoryApply: "Applica le memory selezionate", memoryEmpty: "Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
memorySummary: "Memory per le domande future", archiveRepair: "Risolvi il conflitto negli archivi",
repairError: "La correzione non è stata completata. Verifica lo stato attuale degli archivi prima di riprovare.",
ledgerTitle: "Decisioni registrate in questa fase (dal ledger)",
escapeHelp: "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
inputHelp: "Durante la sessione rispondi con i widget del gate. Per inviare testo libero al modello inizia la riga con '!'.",
memoryIndexWarning: "Memory salvata. L'aggiornamento dell'indice è incompleto; un amministratore può riprovarlo da Gestione Memory.",
backUsage: "Uso: /torna <session_id> [N]",
backInvalid: "Target non valido (fase corrente {phase}).",
decision: "Decisione",
};
const catalogs = { en, it };
function gateText(locale, key) {
const language = String(locale || "en").toLowerCase().split("-")[0];
return catalogs[language]?.[key] ?? en[key];
}
module.exports = { gateText };
+18 -15
View File
@@ -1,9 +1,7 @@
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.
// The composition root supplies manifest language; only deterministic chrome is localized.
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
@@ -31,7 +29,7 @@ function memoryOptionKey(option) {
}
function normalizeMemoryOptions(options) {
function normalizeMemoryOptions(options, locale, gateText) {
const seen = new Set();
const out = [];
for (const option of options) {
@@ -47,7 +45,7 @@ function normalizeMemoryOptions(options) {
].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
const fallbackDetail = [
option.decision?.type && option.decision?.subject
? `Decisione ${option.decision.type}: ${option.decision.subject}`
? `${gateText(locale, "decision")} ${option.decision.type}: ${option.decision.subject}`
: "",
option.decision?.detail ?? "",
].filter(Boolean).join("\n");
@@ -62,12 +60,15 @@ function normalizeMemoryOptions(options) {
}
function memorySelectionWidgetProps(options) {
function memorySelectionWidgetProps(options, locale, gateText) {
return {
title: "Seleziona le memory da applicare alla domanda",
title: gateText(locale, "memoryTitle"),
titleI18n: gateText("en", "memoryTitle"),
selected: options.filter((option) => option.recommended).map((option) => option.id),
selectionLabel: "memory da applicare",
confirmLabel: "Applica le memory selezionate",
selectionLabel: gateText(locale, "memorySelection"),
confirmLabel: gateText(locale, "memoryApply"),
selectionLabelI18n: gateText("en", "memorySelection"),
confirmLabelI18n: gateText("en", "memoryApply"),
};
}
@@ -78,10 +79,11 @@ function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
async function reviewRecall(ctx, params, phase, dependencies) {
const { workflow, ledger, reviewer, waitForReviewer, toTextResult } = dependencies;
const { workflow, ledger, reviewer, waitForReviewer, toTextResult, languageFor, gateText } = dependencies;
try {
const { session, advance } = params;
const options = normalizeMemoryOptions(params.options);
const locale = languageFor(ctx, session);
const options = normalizeMemoryOptions(params.options, locale, gateText);
const typeError = ledger.validate(ctx, options, session);
if (typeError) return toTextResult(typeError);
const meritOptions = options
@@ -99,7 +101,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
advance,
})) {
await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
gateText(locale, "memoryEmpty"),
"info",
);
workflow.advance(ctx, session);
@@ -113,7 +115,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
phase,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...memorySelectionWidgetProps(options),
...memorySelectionWidgetProps(options, locale, gateText),
});
const response = await waitForReviewer(ctx, widget);
if (response.control === "freetext") {
@@ -161,7 +163,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
}
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult }) {
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult, languageFor, gateText }) {
pi.registerTool({
name: "reviewer_memory_promote",
label: "Review Memory summary",
@@ -188,7 +190,8 @@ function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTe
}
const response = await waitForReviewer(ctx, {
id: `u${Date.now()}`, schema_version: 1, phase: phase.id,
widget: "memory-review", title: "Memory for future questions",
widget: "memory-review", title: gateText(languageFor(ctx, session), "memorySummary"),
title_i18n: gateText("en", "memorySummary"),
summary, reserved: ["other", "back", "exit"],
});
if (response.control) return toTextResult(
+6 -3
View File
@@ -1,6 +1,6 @@
import { Type } from "typebox";
export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTextResult }) {
export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTextResult, languageFor, gateText }) {
pi.registerTool({
name: "reviewer_archive_repair",
label: "Resolve Memory / Evidence conflict",
@@ -26,6 +26,7 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
try {
if (!!params.repair_id === !!params.proposal)
return toTextResult("Supply either a new proposal or an existing repair_id.");
const locale = languageFor(ctx, params.session);
const run = args => JSON.parse(memory.execute(ctx,
[...args, "--session", params.session, "--json"]));
let repair = params.repair_id
@@ -36,7 +37,9 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
const response = await waitForReviewer(ctx, {
id: `u${Date.now()}`, schema_version: 1,
phase: workflow.phase(ctx, params.session).id,
widget: "archive-repair", title: "Resolve archive conflict",
widget: "archive-repair", title: gateText(locale, "archiveRepair"),
title_i18n: gateText("en", "archiveRepair"),
...(error ? { error_i18n: gateText("en", "repairError") } : {}),
repair, error, reserved: ["other", "back", "exit"],
});
if (response.control) return toTextResult(response.control === "freetext"
@@ -62,7 +65,7 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
return toTextResult("All repair proposals were rejected as inadequate. " +
"No archive was changed. Reformulate the options for the reviewer.");
} catch (failure) {
error = "The correction could not complete. Review the current archive state before retrying.";
error = gateText(locale, "repairError");
repair = run(["repair-show", "--repair-id", repair.repair_id]);
}
}
+73 -23
View File
@@ -23,6 +23,7 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Type } from "typebox";
import {
buildSelectRequest,
@@ -51,6 +52,7 @@ import {
NEW_SESSION_CLARIFICATION_KICKOFF,
} from "./gate/disambiguation/index.js";
import { isReserved } from "./gate/core/reserved-labels.mjs";
import { gateText } from "./gate/core/locale.js";
// Machine-readable RPC notification consumed by SessionBridge. Pi's extension API
// exposes notify/input but no custom client-event emitter, so this reserved prefix is
@@ -190,6 +192,8 @@ const NUOVA_DOMANDA_KICKOFF =
"orchestri, il reviewer decide, la CLI `tht` persiste; NON sei in modalita' autonoma).\n" +
'1. Esegui `tht session new "<la domanda dell\'utente nel messaggio sopra>"` e annota ' +
"l'id stampato nell'ultima riga.\n" +
"Leggi subito `tht session show <id> --json`: usa interaction_language del manifest " +
"per ogni domanda, spiegazione e scelta al reviewer, prima della prima interazione.\n" +
"2. Segui il workflow dalla Fase 1 (Chiarimento), usando l'id di sessione in ogni " +
"comando `tht`.\n" +
"Regole non negoziabili (valgono SEMPRE, anche senza la skill):\n" +
@@ -249,11 +253,9 @@ function textResult(text) {
// Load THT_* env vars from .env (project root = ctx.cwd).
function loadEnvFromDotenv(ctx) {
const fs = require("node:fs");
const path = require("node:path");
const envPath = path.join(ctx.cwd, ".env");
const envPath = join(ctx.cwd, ".env");
try {
const raw = fs.readFileSync(envPath, "utf8");
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
@@ -262,7 +264,8 @@ function loadEnvFromDotenv(ctx) {
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key.startsWith("THT_") || key.startsWith("PSD_")) {
process.env[key] = value;
// Managed runtime configuration/principal values select the authoritative repository.
process.env[key] ??= value;
}
}
} catch {
@@ -276,6 +279,30 @@ function tht(ctx, args, input) {
return execFileSync("tht", args, { cwd: ctx.cwd, encoding: "utf8", input });
}
const contextLanguages = new WeakMap();
function sessionLanguage(ctx, sessionId) {
const manifest = JSON.parse(tht(ctx, ["session", "ensure-interaction-language", sessionId, "--json"]));
// Fail closed: an unreadable manifest must not silently select a browser/env default.
if (typeof manifest.interaction_language !== "string" || !manifest.interaction_language)
throw new Error("Session interaction language is unavailable");
new Intl.Locale(manifest.interaction_language);
new Intl.Locale(manifest.workspace_language);
contextLanguages.set(ctx, manifest.interaction_language);
return manifest;
}
function languageContext(manifest) {
return "\n\n<session-language>\n" +
`interaction_language=${manifest.interaction_language}\n` +
`workspace_language=${manifest.workspace_language}\n` +
"Use interaction_language for all newly generated reviewer questions, explanations, and choices. " +
"This persisted session setting is authoritative on every turn, including resume and steering. " +
"Preserve workspace documents, quoted sources, prior decisions, SQL, identifiers and literal values. " +
"Interpret domain terms in workspace_language. Instruction/example language does not change these settings.\n" +
"</session-language>";
}
// Runs a privileged tht call; converts any failure into an actionable textResult
// (never propagates a raw "Command failed" to the model).
function relayIfThtFails(ctx, args, recovery, input) {
@@ -589,7 +616,7 @@ async function reLoop(ctx) {
// hasUI is true in RPC too, so this warning would leak to the frontend.
if (ctx.mode === "tui")
await ctx.ui.notify(
"Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
gateText(contextLanguages.get(ctx), "escapeHelp"),
"warning",
);
}
@@ -601,6 +628,10 @@ export default function (pi) {
let lastSteered = false;
let pendingKickoff = null;
let activeSessionId = null;
const languageFor = (ctx, session) => {
activeSessionId = session;
return sessionLanguage(ctx, session).interaction_language;
};
const announcedPhases = new Map();
const announceCurrentPhase = async (ctx, session) => {
if (ctx.mode !== "rpc" || typeof session !== "string" || !session.trim()) return;
@@ -614,6 +645,8 @@ export default function (pi) {
}
};
const memoryGate = createMemoryGate({
languageFor,
gateText,
workflow: {
activate: () => {
lockActive = true;
@@ -631,7 +664,7 @@ export default function (pi) {
try {
const result = JSON.parse(tht(ctx, ["memory", ...args]));
if (result.indexed === false) {
await ctx.ui.notify("Memory saved. Index update is incomplete; an administrator can retry it in Memory management.", "warning");
await ctx.ui.notify(gateText(contextLanguages.get(ctx), "memoryIndexWarning"), "warning");
}
return null;
} catch (error) {
@@ -743,6 +776,7 @@ export default function (pi) {
const raw = (event.text ?? "").trimStart();
// entry detection: workflow-start funziona sia da TUI sia da comando RPC `prompt`.
if (/^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) {
activeSessionId = /^\/riprendi-sessione\s+(\S+)/.exec(raw)?.[1] ?? process.env.THT_SESSION ?? null;
// NOTE: the source runs an ollama preflight here; ThothII defers embeddings
// readiness to the session's first vector op. Entry detection only:
lockActive = true;
@@ -763,8 +797,7 @@ export default function (pi) {
return { action: "transform", text: trimmed.slice(1).trimStart() };
if (ctx.mode === "tui") {
await ctx.ui.notify(
"Durante la sessione rispondi con i widget del gate. " +
"Per inviare testo libero al modello inizia la riga con '!'.",
gateText(contextLanguages.get(ctx), "inputHelp"),
"warning",
);
}
@@ -774,9 +807,10 @@ export default function (pi) {
// 3) BEFORE_AGENT_START: one-shot kickoff injection (appends the operational
// instructions to the system prompt on the turn that starts/resumes a session).
pi.on("before_agent_start", async (event, ctx) => {
await announceCurrentPhase(ctx, process.env.THT_SESSION);
if (!pendingKickoff) return undefined;
const sessionId = process.env.THT_SESSION;
const sessionId = activeSessionId ?? process.env.THT_SESSION;
await announceCurrentPhase(ctx, sessionId);
const language = sessionId ? languageContext(sessionLanguage(ctx, sessionId)) : "";
if (!pendingKickoff) return language ? { systemPrompt: event.systemPrompt + language } : undefined;
const isProvidedNewSession =
Boolean(sessionId) && pendingKickoff === NUOVA_DOMANDA_KICKOFF_PROVIDED(sessionId);
const isResumeSession = pendingKickoff === RIPRENDI_KICKOFF();
@@ -802,7 +836,7 @@ export default function (pi) {
inject +
(retrievalPack
? "\n\n<retrieval-pack>\n" + retrievalPack + "\n</retrieval-pack>"
: ""),
: "") + language,
};
});
@@ -814,7 +848,7 @@ export default function (pi) {
lockActive = false;
lastSteered = false;
}
activeSessionId = null;
if (!pendingKickoff) activeSessionId = null;
announcedPhases.clear();
_phaseMetaCache = null;
});
@@ -824,7 +858,10 @@ export default function (pi) {
// completes, including auto-approved phases that never open a human widget.
pi.on("tool_result", async (event, ctx) => {
const session = typeof event.input?.session === "string" ? event.input.session : null;
if (session) await announceCurrentPhase(ctx, session);
if (session) {
activeSessionId = session;
await announceCurrentPhase(ctx, session);
}
});
// 5) AGENT_END prose safety net: if the model emits prose instead of a reviewer_*
@@ -936,15 +973,18 @@ export default function (pi) {
);
}
const locale = languageFor(ctx, session);
const options = [
{
id: "generate",
label: "Sì, genera il datamart",
label: gateText(locale, "datamartYes"),
label_i18n: gateText("en", "datamartYes"),
decision: { type: "datamart_requested", subject },
},
{
id: "skip",
label: "No, salta il datamart",
label: gateText(locale, "datamartNo"),
label_i18n: gateText("en", "datamartNo"),
decision: { type: "datamart_declined", subject },
recommended: true,
},
@@ -952,10 +992,13 @@ export default function (pi) {
const widget = buildSelectRequest({
id: `u${Date.now()}`,
phase: phaseId(ctx, curNum),
title: "Vuoi generare un datamart?",
title: gateText(locale, "datamartTitle"),
titleI18n: gateText("en", "datamartTitle"),
intro: null,
recommended: "skip",
options: options.map((option) => ({ id: option.id, label: option.label })),
options: options.map((option) => ({
id: option.id, label: option.label, label_i18n: option.label_i18n,
})),
});
const resp = await emitAndWait(ctx, widget);
const outcome = resolveSelectOutcome(options, resp);
@@ -1037,6 +1080,7 @@ export default function (pi) {
.every((o) => o.decision.type === "join_modified");
const widget = joinOnly
? buildJoinReviewRequest({
locale: languageFor(ctx, session),
id: `u${Date.now()}`,
phase,
title,
@@ -1293,6 +1337,7 @@ export default function (pi) {
lockActive = true;
try {
const { session, kind, title } = params;
const locale = languageFor(ctx, session);
let artifact = params.artifact;
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
@@ -1428,6 +1473,7 @@ export default function (pi) {
enriched,
show.decisions,
meta ? meta.emits : [],
locale,
);
} catch {
// ledger non leggibile: il riepilogo resta quello del modello
@@ -1436,18 +1482,22 @@ export default function (pi) {
}
const widget = buildArtifactGate({
locale,
id: `u${Date.now()}`,
phase,
title,
artifact,
action: { kind: "approve_reject", prompt: "Approvi o rifiuti?" },
action: {
kind: "approve_reject", prompt: gateText(locale, "approvalPrompt"),
prompt_i18n: gateText("en", "approvalPrompt"),
},
});
let outcome;
for (;;) {
const resp = await emitAndWait(ctx, widget);
outcome = resolveConfirmOutcome(resp);
if (outcome.kind !== "unknown") break;
await ctx.ui.notify("Scegli «Salva e procedi» o «Rifiuta».", "warning");
await ctx.ui.notify(gateText(locale, "chooseApproval"), "warning");
}
if (outcome.kind === "freetext")
return textResult(
@@ -1729,7 +1779,7 @@ export default function (pi) {
: parts[0];
const sid = sessionId ?? activeSessionId ?? process.env.THT_SESSION;
if (!sid) {
await ctx.ui.notify("Uso: /torna <session_id> [N]", "warning");
await ctx.ui.notify(gateText(contextLanguages.get(ctx), "backUsage"), "warning");
return;
}
const cur = currentPhase(ctx, sid);
@@ -1737,7 +1787,7 @@ export default function (pi) {
const target = targetArg ? parseInt(targetArg, 10) : cur - 1;
if (target < 1 || target >= cur) {
await ctx.ui.notify(
`Target non valido (fase corrente ${cur}).`,
gateText(contextLanguages.get(ctx), "backInvalid").replace("{phase}", String(cur)),
"warning",
);
return;