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;
+3
View File
@@ -4,3 +4,6 @@ argument-hint: "<domanda>"
---
Nuova domanda ThothII: "$@"
Use the session manifest's interaction_language for reviewer dialogue. Workspace
content and SQL retain their original language and values. A managed session already
has its language pinned; standalone `tht session new` defaults to workspace language.
+3
View File
@@ -4,3 +4,6 @@ argument-hint: "<session-id>"
---
Riprendi la sessione ThothII "$1".
Use the persisted interaction_language. For a legacy manifest without it, run
`tht session ensure-interaction-language "$1" --json` before interacting. Resume
accepts no language override. Preserve workspace content, SQL and prior decisions.
+16 -7
View File
@@ -1,6 +1,6 @@
---
name: tht-sessione
description: Orchestrator of the Thoth NL->SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
description: Orchestrator of the Thoth NL-to-SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
---
# Thoth session workflow (phases 1-8)
@@ -17,11 +17,19 @@ confirmation and is persisted directly — an option without a payload only asks
Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column
descriptions and the evidence you read are written in the workspace language (e.g.
`it` for PSD). **These instructions are in English; your output to the reviewer and
your interpretation of domain terms follow the workspace language.** When in doubt
about a domain term, ask the reviewer.
**Language contract:** the session manifest's `interaction_language` controls all
new reviewer questions, explanations, option labels and rationales, including prose
you generate inside review artifacts. It remains authoritative throughout the session,
including resume and steering from a browser using a different UI locale. The gate
injects this persisted language into each model turn; the language of these instructions
and examples does not select the output language.
`workspace.language` controls workspace-owned documents, catalog descriptions, Evidence
and interpretation of domain terms. Preserve quoted source content and prior decisions
verbatim. Keep SQL, identifiers, literal values and workspace artifacts unchanged by
the interaction preference. Ask about ambiguous domain terms in the interaction language.
For a legacy manifest without the field, run `tht session ensure-interaction-language
<id> --json` before interacting: it pins workspace language once and accepts no override.
## Phase map (advance cheat-sheet)
@@ -153,7 +161,8 @@ When launched with `/riprendi-sessione <id>` you have NO prior conversation —
persisted state is your only context. Bootstrap before doing anything else:
1. `tht session show <id> --json` → read `phase` (the current phase N), `status`, and
the manifest (`question`, `database`, `schema`).
the manifest (`question`, `database`, `schema`, `interaction_language`). If language
is absent, run `tht session ensure-interaction-language <id> --json` and use its value.
2. Load the artifacts produced so far with `tht session documents <id> --json`, which
returns their keys and contents directly: `question.md` (revised question),
`schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6),
@@ -1,6 +1,6 @@
---
name: tht-sessione
description: Orchestrator of the Thoth NL->SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
description: Orchestrator of the Thoth NL-to-SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
---
# Thoth session workflow (phases 1-8)
@@ -17,11 +17,19 @@ confirmation and is persisted directly — an option without a payload only asks
Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column
descriptions and the evidence you read are written in the workspace language (e.g.
`it` for PSD). **These instructions are in English; your output to the reviewer and
your interpretation of domain terms follow the workspace language.** When in doubt
about a domain term, ask the reviewer.
**Language contract:** the session manifest's `interaction_language` controls all
new reviewer questions, explanations, option labels and rationales, including prose
you generate inside review artifacts. It remains authoritative throughout the session,
including resume and steering from a browser using a different UI locale. The gate
injects this persisted language into each model turn; the language of these instructions
and examples does not select the output language.
`workspace.language` controls workspace-owned documents, catalog descriptions, Evidence
and interpretation of domain terms. Preserve quoted source content and prior decisions
verbatim. Keep SQL, identifiers, literal values and workspace artifacts unchanged by
the interaction preference. Ask about ambiguous domain terms in the interaction language.
For a legacy manifest without the field, run `tht session ensure-interaction-language
<id> --json` before interacting: it pins workspace language once and accepts no override.
## Phase map (advance cheat-sheet)
@@ -151,7 +159,8 @@ When launched with `/riprendi-sessione <id>` you have NO prior conversation —
persisted state is your only context. Bootstrap before doing anything else:
1. `tht session show <id> --json` → read `phase` (the current phase N), `status`, and
the manifest (`question`, `database`, `schema`).
the manifest (`question`, `database`, `schema`, `interaction_language`). If language
is absent, run `tht session ensure-interaction-language <id> --json` and use its value.
2. Load the artifacts produced so far with `tht session documents <id> --json`, which
returns their keys and contents directly: `question.md` (revised question),
`schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6),
+4 -2
View File
@@ -12,8 +12,10 @@ The rewritten question must:
3. Replace ambiguous terms with the concepts clarified in Phase 1, citing the
evidence that defines them when it exists (evidence id in parentheses).
4. Make the expected output explicit: count, list, aggregate, trend.
5. Stay in the workspace language, in a form a second reviewer would understand
without having seen the conversation.
5. Write the persisted question document in the workspace language, in a form a second
reviewer would understand without having seen the conversation. Explanations and
choices addressed to the reviewer use the manifest's interaction_language; preserve
domain terms, identifiers and quoted values in either presentation.
Format of the proposal to the reviewer:
+2 -1
View File
@@ -83,7 +83,8 @@
"memory rules",
"memory retry",
"memory show",
"memory update"
"memory update",
"session ensure-interaction-language"
],
"erased": [
"cte list",
+3 -1
View File
@@ -35,7 +35,9 @@ def test_typer_tree_matches_the_approved_command_surface():
expected = set(approved["maintained"]) | set(approved["enhanced"])
assert len(approved["maintained"]) == 61
assert len(approved["enhanced"]) == 21
# Approved shell spec (2026-09-13), session ticket #35: backend/Pi use the
# public harness operation to pin legacy language through either repository.
assert len(approved["enhanced"]) == 22
assert len(approved["erased"]) == 14
assert not (expected & set(approved["erased"]))
assert _leaf_paths(get_command(app)) == expected
+2 -1
View File
@@ -9,7 +9,8 @@ from tht.pi_skill_projection import (
render_projection,
)
BASELINE_SHA256 = "314d5e62eecda59dc16f00946d1f814829a326e7b117e102fd03c61f1f00a1a1"
# Approved session-language contract (#35); unrelated workflow fragments stay byte-identical.
BASELINE_SHA256 = "a2e6608e1aa07735ef92d96b3a87fca3bfbe3636754330ff6749178c87a37493"
def test_modular_pi_skill_renders_the_byte_identical_approved_projection():
@@ -64,6 +64,69 @@ def test_owner_can_read_own_snapshot_but_not_another_owners(database_url):
bob.get(session_id)
def test_legacy_interaction_language_is_atomic_idempotent_and_owner_scoped(database_url):
session_id = str(uuid.uuid4())
owner = _repository(database_url, "language-owner")
foreign = _repository(database_url, "language-foreign")
owner.create(_manifest(session_id))
owner.write_artifact(session_id, "sql_final", "SELECT 'paziente' AS nome")
with pytest.raises(SessionError, match="Sessione non trovata"):
foreign.ensure_interaction_language(session_id, "en")
barrier = Barrier(2)
def pin(language):
barrier.wait()
return owner.ensure_interaction_language(session_id, language).manifest.interaction_language
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(pin, ["it", "en"]))
assert results[0] == results[1]
saved = owner.get(session_id)
repeated = owner.ensure_interaction_language(session_id, "fr")
assert repeated == saved
assert saved.artifacts["sql_final"] == "SELECT 'paziente' AS nome"
def test_cli_creation_and_resume_use_postgres_language_authority(database_url, monkeypatch, tmp_path):
import json
from types import SimpleNamespace
from sqlalchemy.engine import make_url
from typer.testing import CliRunner
from tht.cli import session_cmd
from tht.config import DatabaseConfig
url = make_url(database_url)
connection = SimpleNamespace(user=url.username, password=url.password, host=url.host,
port=url.port, database=url.database, sslmode="disable",
sslrootcert=None)
cfg = SimpleNamespace(language="it", session_storage=SimpleNamespace(connection=connection),
database=DatabaseConfig(database="testdb", user="u", password="p",
schema="public"),
paths=SimpleNamespace(sessions=tmp_path / "unused"))
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: cfg)
monkeypatch.setenv("THT_PRINCIPAL_ISSUER", "portal")
monkeypatch.setenv("THT_PRINCIPAL_SUBJECT", "cli-language")
def invoke(*args):
result = CliRunner().invoke(session_cmd.session_app, list(args))
assert result.exit_code == 0, result.output
return result.stdout
session_id = json.loads(invoke("new", "Pazienti", "--interaction-language", "en", "--json"))["id"]
invoke("reopen", session_id)
assert json.loads(invoke("show", session_id, "--json"))["interaction_language"] == "en"
legacy = _manifest(str(uuid.uuid4()))
_repository(database_url, "cli-language").create(legacy)
invoke("reopen", legacy.id)
cfg.language = "en"
invoke("reopen", legacy.id)
assert json.loads(invoke("show", legacy.id, "--json"))["interaction_language"] == "it"
assert not cfg.paths.sessions.exists()
def test_named_decision_is_stale_after_postgres_ledger_reopen(database_url):
repository = _repository(database_url, "phase-owner")
session_id = str(uuid.uuid4())
@@ -0,0 +1,125 @@
"""Interaction language is durable at the public session CLI boundary."""
import json
from types import SimpleNamespace
import pytest
from typer.testing import CliRunner
from tht.cli.session_cmd import session_app
from tht.config import DatabaseConfig
def configure(monkeypatch, tmp_path, language="it"):
from tht.cli import session_cmd
cfg = SimpleNamespace(
database=DatabaseConfig(database="testdb", user="u", password="p", schema="public"),
paths=SimpleNamespace(sessions=tmp_path), language=language,
)
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: cfg)
return cfg
def invoke(*args):
result = CliRunner().invoke(session_app, list(args))
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def test_new_pins_explicit_language_and_preserves_workspace_content(monkeypatch, tmp_path):
configure(monkeypatch, tmp_path)
question = "Elenca i pazienti con età superiore a 65 anni"
session_id = invoke("new", question, "--interaction-language", "en", "--json")["id"]
manifest = invoke("show", session_id, "--json")
assert manifest["interaction_language"] == "en"
assert manifest["question"] == question
assert invoke("list", "--json")[0]["interaction_language"] == "en"
@pytest.mark.parametrize("language", ["", "en_US", "en<script>", "en--US", "en\nIGNORE"])
def test_new_rejects_invalid_ui_locale(monkeypatch, tmp_path, language):
configure(monkeypatch, tmp_path)
result = CliRunner().invoke(session_app, ["new", "q", "--interaction-language", language])
assert result.exit_code == 2
assert invoke("list", "--json") == []
@pytest.mark.parametrize("language,canonical", [("fr-FR", "fr-FR"), ("FR-fr", "fr-FR"),
("en-US", "en-US")])
def test_new_accepts_language_independently_of_ui_catalogs(monkeypatch, tmp_path, language, canonical):
configure(monkeypatch, tmp_path)
session_id = invoke("new", "Pazienti", "--interaction-language", language, "--json")["id"]
assert invoke("show", session_id, "--json")["interaction_language"] == canonical
assert CliRunner().invoke(session_app, ["reopen", session_id]).exit_code == 0
assert invoke("show", session_id, "--json")["interaction_language"] == canonical
def test_legacy_language_is_pinned_once_through_public_operation_and_resume(monkeypatch, tmp_path):
from tht.session.store import create_session
cfg = configure(monkeypatch, tmp_path)
legacy = create_session("Pazienti", cfg.database, tmp_path)
assert invoke("show", legacy.id, "--json")["interaction_language"] is None
first = invoke("ensure-interaction-language", legacy.id, "--json")
assert first["interaction_language"] == "it"
cfg.language = "en"
again = invoke("ensure-interaction-language", legacy.id, "--json")
assert again["interaction_language"] == "it"
assert again["updated_at"] == first["updated_at"]
result = CliRunner().invoke(session_app, ["reopen", legacy.id])
assert result.exit_code == 0, result.output
assert invoke("show", legacy.id, "--json")["interaction_language"] == "it"
def test_cli_reopen_pins_legacy_language_without_rewriting_artifacts(monkeypatch, tmp_path):
from tht.session.store import create_session
cfg = configure(monkeypatch, tmp_path)
legacy = create_session("Pazienti", cfg.database, tmp_path)
before = invoke("documents", legacy.id, "--json")
result = CliRunner().invoke(session_app, ["reopen", legacy.id])
assert result.exit_code == 0, result.output
assert invoke("show", legacy.id, "--json")["interaction_language"] == "it"
assert invoke("documents", legacy.id, "--json") == before
def test_cli_defaults_to_workspace_and_canonicalizes_explicit_ui_language(monkeypatch, tmp_path):
configure(monkeypatch, tmp_path)
default_id = invoke("new", "Pazienti", "--json")["id"]
assert invoke("show", default_id, "--json")["interaction_language"] == "it"
explicit_id = invoke("new", "Pazienti", "--interaction-language", "EN", "--json")["id"]
assert invoke("show", explicit_id, "--json")["interaction_language"] == "en"
def test_archived_legacy_session_is_not_pinned_or_reopened(monkeypatch, tmp_path):
from tht.session.store import create_session
cfg = configure(monkeypatch, tmp_path)
legacy = create_session("Pazienti", cfg.database, tmp_path)
assert CliRunner().invoke(session_app, ["archive", legacy.id]).exit_code == 0
for command in ("reopen", "ensure-interaction-language"):
result = CliRunner().invoke(session_app, [command, legacy.id])
assert result.exit_code == 1
assert invoke("show", legacy.id, "--json")["interaction_language"] is None
def test_language_pin_race_on_file_repository_keeps_one_value(monkeypatch, tmp_path):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from tht.cli.session_cmd import session_repository
from tht.session.store import create_session
cfg = configure(monkeypatch, tmp_path)
legacy = create_session("Pazienti", cfg.database, tmp_path)
repository = session_repository(cfg)
barrier = Barrier(2)
def pin(language):
barrier.wait()
return repository.ensure_interaction_language(legacy.id, language).manifest.interaction_language
with ThreadPoolExecutor(max_workers=2) as pool:
languages = list(pool.map(pin, ["it", "en"]))
assert languages[0] == languages[1]
assert invoke("show", legacy.id, "--json")["interaction_language"] == languages[0]
+50 -3
View File
@@ -144,6 +144,7 @@ def _list_sessions(sessions_root: Path) -> list[dict]:
"archived": m.archived,
"workspace_id": m.workspace_id,
"workspace_revision": m.workspace_revision,
"interaction_language": m.interaction_language,
})
out.sort(key=lambda r: r["created_at"], reverse=True)
return out
@@ -162,7 +163,8 @@ def list_cmd(
"updated_at": s.manifest.updated_at.isoformat() if s.manifest.updated_at else None,
"author": s.manifest.author, "name": s.manifest.name, "group": s.manifest.group,
"archived": s.manifest.archived, "workspace_id": s.manifest.workspace_id,
"workspace_revision": s.manifest.workspace_revision}
"workspace_revision": s.manifest.workspace_revision,
"interaction_language": s.manifest.interaction_language}
for s in session_repository(cfg).list()
]
if json_out:
@@ -175,6 +177,9 @@ def list_cmd(
@session_app.command("new")
def new_cmd(
question: str = typer.Argument(..., help="La domanda in linguaggio naturale."),
interaction_language: str = typer.Option(
None, "--interaction-language", help="BCP-47 interaction language; defaults to workspace language."
),
provider: str = typer.Option(None, "--provider", help="Provider LLM (es. zai, anthropic)."),
model: str = typer.Option(None, "--model", help="Modello LLM (es. glm-5.2)."),
thinking: str = typer.Option(None, "--thinking", help="Livello di thinking (es. medium)."),
@@ -188,10 +193,18 @@ def new_cmd(
from tht.session.store import _extract_name, new_session_manifest, render_question_md
cfg = _load_config_or_exit(config)
from tht.session.language import validate_language_tag
try:
language = validate_language_tag(
interaction_language if interaction_language is not None else getattr(cfg, "language", "en")
)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--interaction-language") from None
manifest = new_session_manifest(question, cfg.database,
provider=provider, model=model, thinking=thinking,
workspace_id=workspace_id, workspace_revision=workspace_revision,
name=name or _extract_name(question))
name=name or _extract_name(question), interaction_language=language)
repository = session_repository(cfg)
repository.create(manifest)
repository.write_artifact(manifest.id, "question", render_question_md(question))
@@ -306,6 +319,7 @@ def show_cmd(
typer.echo(f"stato : {manifest.status}")
typer.echo(f"domanda : {manifest.question}")
typer.echo(f"target : {manifest.database} / {manifest.db_schema}")
typer.echo(f"interaction_language: {manifest.interaction_language or '(legacy: not pinned)'}")
typer.echo(f"decisioni: {len(decisions)}")
for d in decisions[-10:]:
typer.echo(f" [{d.seq}] {d.type}: {d.subject}" + (f" — {d.detail}" if d.detail else ""))
@@ -352,11 +366,44 @@ def fail_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -
session_repository(cfg).save_manifest(snapshot.manifest)
typer.secho(f"OK: sessione {session_id} marcata failed.", fg=typer.colors.RED)
def ensure_session_language_or_exit(cfg, session_id: str):
from tht.session.store import SessionError
try:
return session_repository(cfg).ensure_interaction_language(
session_id, getattr(cfg, "language", "en")
).manifest
except (SessionError, ValueError) as exc:
typer.echo(f"ERROR: {exc}", err=True)
raise typer.Exit(code=1) from None
@session_app.command("ensure-interaction-language")
def ensure_interaction_language_cmd(
session_id: str = typer.Argument(...),
json_out: bool = typer.Option(False, "--json", help="Emit pristine JSON."),
config: Path = CONFIG_OPT,
) -> None:
"""Pin a missing legacy language from the workspace; never override a saved value."""
cfg = _load_config_or_exit(config)
manifest = ensure_session_language_or_exit(cfg, session_id)
if json_out:
payload = manifest.model_dump(by_alias=True, mode="json")
payload["workspace_language"] = getattr(cfg, "language", "en")
typer.echo(json.dumps(payload, ensure_ascii=False))
else:
typer.echo(manifest.interaction_language)
@session_app.command("reopen")
def reopen_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None:
"""Riapre una sessione per una ripresa manuale."""
cfg = _load_config_or_exit(config)
load_session_or_exit(cfg, session_id)
manifest = load_session_or_exit(cfg, session_id)
if manifest.status == "finalized" or manifest.archived:
typer.echo("ERROR: Session is read-only (finalized or archived)", err=True)
raise typer.Exit(code=1)
ensure_session_language_or_exit(cfg, session_id)
snapshot = load_snapshot_or_exit(cfg, session_id)
snapshot.manifest.status = "open"
session_repository(cfg).save_manifest(snapshot.manifest)
+1 -1
View File
@@ -603,7 +603,7 @@ class Config(BaseModel):
# richiedono vector_write_rest, mentre init/clear/rebuild restano solo-server.
profile: Literal["server", "workstation"] = "server"
# Language in which table/column descriptions and evidence are written. The skill
# instructions stay in English; only content/output follow this language. Default
# instructions stay in English; session dialogue uses interaction_language. Default
# 'en' so Thoth is not bound to any customer's language.
language: str = "en"
paths: PathsConfig = PathsConfig()
@@ -106,6 +106,19 @@ class FilesystemSessionRepository:
path = self._artifact_path(session_dir, key)
return path.read_text() if path.exists() else None
def ensure_interaction_language(
self, session_id: str, workspace_language: str
) -> SessionSnapshot:
from tht.session.store import pin_interaction_language
session_dir = self._session_dir(session_id)
with self._lock(session_dir):
self._require_existing(session_dir, session_id)
manifest = SessionManifest.from_yaml(session_dir / MANIFEST)
if pin_interaction_language(manifest, workspace_language):
self._write_manifest(session_dir, manifest)
return self.get(session_id)
def write_artifact(self, session_id: str, key: str, content: str) -> None:
session_dir = self._session_dir(session_id)
with self._lock(session_dir):
+23
View File
@@ -0,0 +1,23 @@
"""Session language policy, independent of workspace document content."""
import re
_LANGUAGE_TAG = re.compile(
r"(?P<language>[A-Za-z]{2,8})(?:-(?P<script>[A-Za-z]{4}))?"
r"(?:-(?P<region>[A-Za-z]{2}|[0-9]{3}))?"
r"(?P<rest>(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*"
r"(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*"
r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?)"
)
def validate_language_tag(value: str) -> str:
"""Validate syntax and normalize casing without depending on UI catalogs."""
match = _LANGUAGE_TAG.fullmatch(value) if isinstance(value, str) else None
if match is None:
raise ValueError("interaction_language must be a BCP-47 language tag")
parts = [match["language"].lower()]
if match["script"]:
parts.append(match["script"].title())
if match["region"]:
parts.append(match["region"].upper())
return "-".join(parts) + match["rest"].lower()
+9 -1
View File
@@ -8,7 +8,7 @@ from typing import Literal, Self
import portalocker
import yaml
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from tht.decisions import DecisionRecord
@@ -119,10 +119,18 @@ class SessionManifest(_YamlModel):
thinking: str | None = None
workspace_id: str | None = None
workspace_revision: str | None = None
interaction_language: str | None = None
name: str | None = None
archived: bool = False
group: str | None = None
@field_validator("interaction_language")
@classmethod
def valid_interaction_language(cls, value: str | None) -> str | None:
from tht.session.language import validate_language_tag
return validate_language_tag(value) if value is not None else None
class SessionSnapshot(BaseModel):
"""The current persisted session state, excluding the non-persistent chat stream."""
@@ -306,6 +306,31 @@ class PostgresSessionRepository:
raise SessionError(f"Sessione non trovata: {manifest.id}")
return self.get(manifest.id)
def ensure_interaction_language(
self, session_id: str, workspace_language: str
) -> SessionSnapshot:
from tht.session.store import pin_interaction_language
self._require_uuid4(session_id)
with self._transaction() as connection:
self._lock_session(connection, session_id)
row = connection.execute(
text("SELECT manifest FROM thoth_sessions.sessions WHERE id = :id FOR UPDATE"),
{"id": session_id},
).mappings().first()
if row is None:
raise SessionError(f"Sessione non trovata: {session_id}")
manifest = SessionManifest.model_validate(row["manifest"])
if pin_interaction_language(manifest, workspace_language):
connection.execute(
text("UPDATE thoth_sessions.sessions "
"SET manifest = CAST(:manifest AS jsonb), updated_at = pg_catalog.now() "
"WHERE id = :id"),
{"id": session_id,
"manifest": json.dumps(manifest.model_dump(by_alias=True, mode="json"))},
)
return self.get(session_id)
def read_artifact(self, session_id: str, key: str) -> str | None:
self._require_uuid4(session_id)
self._require_artifact_key(key)
+4
View File
@@ -24,6 +24,10 @@ class SessionRepository(Protocol):
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot: ...
def ensure_interaction_language(
self, session_id: str, workspace_language: str
) -> SessionSnapshot: ...
def read_artifact(self, session_id: str, key: str) -> str | None: ...
def write_artifact(self, session_id: str, key: str, content: str) -> None: ...
+16 -1
View File
@@ -135,7 +135,7 @@ def create_session(
def new_session_manifest(
question: str, db: DatabaseConfig, *, provider=None, model=None, thinking=None,
workspace_id=None, workspace_revision=None, name=None,
workspace_id=None, workspace_revision=None, name=None, interaction_language=None,
) -> SessionManifest:
"""Create an unsaved UUIDv4 manifest for a repository-owned session."""
now = datetime.now(UTC)
@@ -145,9 +145,24 @@ def new_session_manifest(
summary=_summarize(question), updated_at=now, updated_by=current_author(),
provider=provider, model=model, thinking=thinking, workspace_id=workspace_id,
workspace_revision=workspace_revision, name=name,
interaction_language=interaction_language,
)
def pin_interaction_language(manifest: SessionManifest, workspace_language: str) -> bool:
"""Apply legacy compatibility once, while the repository holds its writer lock."""
from tht.session.language import validate_language_tag
if manifest.interaction_language is not None:
return False
if manifest.status == "finalized" or manifest.archived:
raise SessionError("Session is read-only (finalized or archived)")
manifest.interaction_language = validate_language_tag(workspace_language)
manifest.updated_at = datetime.now(UTC)
manifest.updated_by = current_author()
return True
_ASSUMPTIONS_HEADING = re.compile(
r"^#{1,6}\s+(?:assunzioni|assumptions)\s*$", re.IGNORECASE | re.MULTILINE
)