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:
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user