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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user