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
+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;