2026-09-25 23:32:13 +03:00
import { Type } from "typebox" ;
import type { AgentTool } from "@earendil-works/pi-agent-core" ;
import { questionResult , serverExecute , ToolInputError } from "./tool-execute.ts" ;
2026-09-11 06:44:20 +03:00
import {
askInputSchema ,
2026-09-25 23:32:13 +03:00
askOptionProblems ,
2026-09-27 08:55:13 +03:00
explainInputBase ,
explainInputObject ,
explainInputProblems ,
2026-09-26 19:08:39 +03:00
parseOptionsIfString ,
2026-09-11 06:44:20 +03:00
probeQuizInputSchema ,
quizInputObject ,
quizInputProblems ,
} from "../core/question-core.ts" ;
2026-09-19 14:06:07 +03:00
import { filterEnabledTools , type EnabledTools } from "../core/pending.ts" ;
2026-09-01 19:15:43 +03:00
import type { JobRecord } from "../core/contracts.ts" ;
import type { JobStore } from "../core/ports.ts" ;
2026-09-22 16:22:57 +03:00
import { jobTools , type DemoInput , type PdfInput , type ResearchInput , type VisualInput } from "./tools/jobs.ts" ;
2026-09-01 19:15:43 +03:00
import { planTools , type PlanToolContext } from "./tools/plan.ts" ;
2026-09-11 06:44:20 +03:00
import { probeDoneTool , type ProbeDoneContext } from "./tools/probe.ts" ;
2026-09-23 15:24:48 +03:00
import { proberGraphTools } from "./tools/graph.ts" ;
2026-09-19 15:24:26 +03:00
import { readTool } from "./tools/read.ts" ;
2026-09-19 16:50:20 +03:00
import { lsTool } from "./tools/ls.ts" ;
import { findTool } from "./tools/find.ts" ;
import { grepTool } from "./tools/grep.ts" ;
2026-09-23 15:24:48 +03:00
import type { GraphStore } from "../core/ports.ts" ;
2026-08-31 22:16:03 +03:00
export { jobTools };
2026-09-11 06:44:20 +03:00
export type { ProbeDoneContext } from "./tools/probe.ts" ;
2026-08-22 02:18:27 +03:00
/**
2026-09-11 06:44:20 +03:00
* The agent-split tool composition (docs/probe-teach-split.md §1): the
* PROBER and the TEACHER are different agents with different tool sets.
* The question tools share a NAME but are genuinely DIFFERENT tools —
2026-09-25 23:32:13 +03:00
* the prober's quiz has NO nodeId field at all (its declaration is built
* from quizInputBase; the model's schema never mentions nodeId), the
2026-09-11 06:44:20 +03:00
* teacher's carries the validated position field.
2026-08-22 02:18:27 +03:00
*
2026-09-25 23:32:13 +03:00
* - Question tools (quiz / ask_user_question): CLIENT-ANSWERED. Their
* execute validates the authoring refinements (the cross-field rules no
* schema carries — quizInputProblems / askOptionProblems; a failure
* throws ToolInputError, which pi's loop converts to an error tool
* result the model retries on), then returns the synthetic
* "question posed" placeholder (questionResult: terminate: true — the
* turn ends; the call streams to the browser in `input-available`
* state; the learner answers in the dock; the client grades and sends
* the verdict with addToolOutput). Input schemas live in
* core/question-core. Question tools opt into SEQUENTIAL execution so
* parallel calls resolve in the model's call order (the dock's subject
* can never flip mid-turn).
2026-09-11 06:44:20 +03:00
* - probe_done (src/agent/tools/probe.ts): SERVER-SIDE seam tool.
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once
* plan_init. Teacher only.
* - jobTools (src/agent/tools/jobs.ts): SERVER-EXECUTED dispatch. Teacher
* only.
2026-08-22 02:18:27 +03:00
*/
2026-09-26 19:08:39 +03:00
/** GLM wobble guard (owner-approved, 2026-09-26): models sometimes emit
* `options` as a JSON-encoded string instead of an array; pi runs this
* BEFORE schema validation, so a double-encoded call lands instead of
* bouncing back as a validation error + wasted retry turn. NOTE: pi
* persists the tool call with its RAW arguments — the client coerces
* again in normalizeOptions (question-core), the same parseOptionsIfString.
* Anything unparseable passes through untouched: validation then reports
* honestly and the agent retries with a real error to learn from. */
function prepareQuestionArguments < T extends object >( args : unknown ) : T {
if ( args && typeof args === "object" && ! Array . isArray ( args )) {
const o = args as { options? : unknown };
if ( typeof o . options === "string" ) {
const parsed = parseOptionsIfString ( o . options );
if ( parsed !== o . options ) return { ... o , options : parsed } as T ;
}
return o as T ;
}
return args as T ;
}
2026-09-25 23:32:13 +03:00
export const askUserQuestionTool : AgentTool < typeof askInputSchema > = {
2026-09-12 02:34:18 +03:00
name : "ask_user_question" ,
2026-09-25 23:32:13 +03:00
label : "ask_user_question" ,
2026-08-22 02:18:27 +03:00
description :
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
2026-09-25 20:51:45 +03:00
"or you need an open-ended response. ONE question per turn: call a single ask_user_question, then end your " +
"turn — his answer determines the next." ,
2026-09-25 23:32:13 +03:00
parameters : askInputSchema ,
2026-09-26 19:08:39 +03:00
prepareArguments : prepareQuestionArguments ,
2026-09-25 23:32:13 +03:00
executionMode : "sequential" ,
execute : async ( _toolCallId , input ) => {
const problems = askOptionProblems ( input . options ?? []);
if ( problems . length > 0 ) throw new ToolInputError ( "ask_user_question" , problems );
return questionResult ();
},
2026-09-12 02:34:18 +03:00
};
2026-08-22 02:18:27 +03:00
2026-09-11 06:44:20 +03:00
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
2026-08-22 02:18:27 +03:00
2026-09-27 08:55:13 +03:00
/** The option-building craft (owner 2026-09-27: moved OUT of the system
* prompt into the tool description — pi-native doctrine travels with
* the armed tool). Shared verbatim by both quiz variants. */
const QUIZ_OPTION_CRAFT =
"Building options — the giveaway is never the distractors' wrongness, it's asymmetry: the correct option " +
"carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: " +
"(1) every option is a bare claim — zero 'why' in any of them; all reasoning goes in explanation, shown only " +
"after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone " +
"holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real " +
"error he might actually make (his pick is diagnostic) yet unambiguously wrong; (4) no asymmetric bolding. If " +
"you can tell the right one cold without knowing the material, regenerate — don't patch." ;
/** The explain doctrine (owner 2026-09-27): free-recall quizzing — the
* sycophancy mitigation is the commitment. Carried by the DESCRIPTION
* (pi-native ruling), not the system prompt. Shared by both variants;
* the teach variant appends the nodeId tagging paragraph. */
const EXPLAIN_DOCTRINE =
"Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " +
2026-09-27 09:13:34 +03:00
"answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: write correctAnswer — the FULL " +
"correct answer in your own words, reasoning included — committed before he ever sees the question. When his answer returns, grade it strictly against your committed answer in your reply: never " +
2026-09-27 08:55:13 +03:00
"revise the commitment after seeing his words, and never mark a wrong answer right to be kind — a near-miss is " +
2026-09-27 09:13:34 +03:00
"partial understanding; name exactly what he got and what he missed. He sees the committed answer on submit. If he genuinely doesn't know, he says so in his own words — that is a genuine " +
"gap, not a wrong answer. " +
2026-09-27 08:55:13 +03:00
"ONE question per turn: call a single explain, then end your turn — his answer determines the next." ;
2026-09-25 23:32:13 +03:00
export const probeQuizTool : AgentTool < typeof probeQuizInputSchema > = {
2026-09-12 02:34:18 +03:00
name : "quiz" ,
2026-09-25 23:32:13 +03:00
label : "quiz" ,
2026-09-11 06:44:20 +03:00
description :
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
"can show an explanation. Use it to locate the edge of his understanding: bracket each strand the lesson will " +
"depend on — something he gets right (a floor), something he gets wrong or doesn't know (a ceiling). " +
"Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can " +
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
2026-09-25 20:51:45 +03:00
"questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
2026-09-27 08:55:13 +03:00
"his answer determines the next question to ask. " + QUIZ_OPTION_CRAFT ,
2026-09-25 23:32:13 +03:00
parameters : probeQuizInputSchema ,
2026-09-26 19:08:39 +03:00
prepareArguments : prepareQuestionArguments ,
2026-09-25 23:32:13 +03:00
executionMode : "sequential" ,
execute : async ( _toolCallId , input ) => {
const problems = quizInputProblems ( input );
if ( problems . length > 0 ) throw new ToolInputError ( "quiz" , problems );
return questionResult ();
},
2026-09-12 02:34:18 +03:00
};
2026-09-11 06:44:20 +03:00
2026-09-27 08:55:13 +03:00
/** The teacher's free-recall variant (nodeId-validated, same seam as the
* teach quiz — phases/plan recognize BOTH tool names). */
export function teachExplainToolFor (
ctx : Pick < PlanToolContext , "chatId" | "chatStore" >,
) : AgentTool < typeof explainInputObject > {
return {
name : "explain" ,
label : "explain" ,
description :
EXPLAIN_DOCTRINE +
" Set `nodeId` (required) to the plan node this question is about — the lesson's position on the map derives " +
"from those tags; a node takes as many questions as it deserves, so don't rush forward. Off-map checks use " +
"ask_user_question." ,
parameters : explainInputObject ,
prepareArguments : prepareQuestionArguments ,
executionMode : "sequential" ,
execute : async ( _toolCallId , input ) => {
const problems = [... explainInputProblems ( input )];
const nodeIdProblemText = nodeIdProblem ( ctx , input . nodeId );
if ( nodeIdProblemText ) problems . push ( nodeIdProblemText );
if ( problems . length > 0 ) throw new ToolInputError ( "explain" , problems );
return questionResult ();
},
};
}
/** The prober's free-recall variant (no nodeId — same pattern as the
* probe quiz). */
export const probeExplainTool : AgentTool < typeof explainInputBase > = {
name : "explain" ,
label : "explain" ,
description : EXPLAIN_DOCTRINE ,
parameters : explainInputBase ,
prepareArguments : prepareQuestionArguments ,
executionMode : "sequential" ,
execute : async ( _toolCallId , input ) => {
const problems = explainInputProblems ( input );
if ( problems . length > 0 ) throw new ToolInputError ( "explain" , problems );
return questionResult ();
},
};
2026-09-22 16:22:57 +03:00
/** The prober's registry context: the probe's own needs (probe_done —
* ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling
* (2026-09-20) puts research + pdf in the probe phase's registry too. */
export interface ProberToolContext extends ProbeDoneContext {
/** Creates the job record (deduped by toolCallId). */
jobStore : JobStore ;
/** Fire the background runner for a freshly created job. */
spawn : ( job : JobRecord , input : ResearchInput | DemoInput | VisualInput | PdfInput ) => void ;
/** Search availability — the research dispatch tool's honest-disable
* signal (same gate as the teacher's). Absent = available. */
searchAvailable ?: () => boolean ;
2026-09-23 15:24:48 +03:00
/** The prober's graph-map reader (wave 7): present = the two map tools
* (list_sessions / list_propositions) are registered. The composition
* root passes it only when graphToolsEnabled(effective) — the graph
* master switch AND the agent.graphTools toggle (settings-resolution).
* Absent = the gate is closed, no map tools (tests drive this seam). */
graphStore? : GraphStore ;
2026-09-22 16:22:57 +03:00
}
/** The prober's tool set: probe quiz (no nodeId) + ask + the learner-fs
2026-09-23 15:24:48 +03:00
* set + probe_done + the two phase-omnipresent job dispatch tools +
* (wave 7, gated) the graph-map reader. `enabled` is the resolved
* settings seam (core/pending.ts): any tool explicitly false is dropped
* from the registry. probe_done stays always-on regardless — it is how
* the phase ENDS; a prober that can never conclude is a lockout. */
2026-09-19 14:06:07 +03:00
export function createProberTools (
2026-09-22 16:22:57 +03:00
ctx : ProberToolContext ,
2026-09-19 14:06:07 +03:00
enabled? : EnabledTools ,
2026-09-25 23:32:13 +03:00
) : Record < string , AgentTool < any >> {
2026-09-19 14:06:07 +03:00
// probe_done is re-added AFTER the filter, always-on by construction — it
// is how the phase ENDS; a prober that can never conclude is a lockout.
const done = probeDoneTool ( ctx );
2026-09-22 16:22:57 +03:00
// The job tools build over the SAME context/gates as the teacher's
// (one builder, one gating semantic); the prober carries a SUBSET:
const jobs = jobTools ({
chatId : ctx.chatId ,
jobStore : ctx.jobStore ,
spawn : ctx.spawn ,
searchAvailable : ctx.searchAvailable ,
});
2026-09-27 08:55:13 +03:00
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the probe
// quiz for the probe explain — one quizzing tool at a time, whichever
// the settings arm. ask stays always-on.
const mechanics = quizMechanics ( enabled );
// The armed quizzing tool, widened to the registry's heterogeneous
// AgentTool record BEFORE the filter (a conditional spread of two
// differently-typed literals makes the whole object's type a union —
// unusable as Record<string, AgentTool<any>> downstream).
const quizzing : Record < string , AgentTool < any >> =
mechanics === "free" ? { explain : probeExplainTool } : { quiz : probeQuizTool };
2026-09-11 06:44:20 +03:00
return {
2026-09-19 14:06:07 +03:00
... filterEnabledTools (
{
2026-09-27 08:55:13 +03:00
... quizzing ,
2026-09-19 14:06:07 +03:00
ask_user_question : askUserQuestionTool ,
2026-09-19 16:50:20 +03:00
// The learner-filesystem set is always-on (user-decided 2026-09,
// sources v1): the prober inspects and LOCATES learner-referenced
// files while assessing the edge. Read-only by construction.
2026-09-19 15:24:26 +03:00
read : readTool ,
2026-09-19 16:50:20 +03:00
ls : lsTool ,
find : findTool ,
grep : grepTool ,
2026-09-22 16:22:57 +03:00
// Job dispatch in the probe phase (owner ruling, 2026-09-20):
// research + pdf are ALWAYS available in every phase — the
// prober can verify facts or read learner PDFs while assessing.
// demo/visual stay teach-phase-only (they need the lesson's plan
// context). Same gating as the teacher: toggle-filtered here,
// research subject to the searchAvailable gate (honestly absent
// when no engine is live).
...( jobs . research ? { research : jobs.research } : {}),
pdf : jobs.pdf ! ,
2026-09-23 15:24:48 +03:00
// The map reader (wave 7): present only when the settings gate
// opened — same registration-time drop as the search gate.
...( ctx . graphStore
? proberGraphTools ({ chatStore : ctx.chatStore , graphStore : ctx.graphStore })
: {}),
2026-09-19 14:06:07 +03:00
... done ,
},
2026-09-27 08:55:13 +03:00
withoutMechanics ( enabled ),
2026-09-19 14:06:07 +03:00
),
... done ,
2026-09-11 06:44:20 +03:00
};
}
// ─── the teacher's question tools (quiz carries the position field) ──────────
2026-09-25 23:32:13 +03:00
/** The nodeId-against-live-tree check the teacher's quiz chains after the
* shared authoring contract (the old superRefine, verbatim). */
function nodeIdProblem ( ctx : Pick < PlanToolContext , "chatId" | "chatStore" >, nodeId : string ) : string | null {
const record = ctx . chatStore . getChat ( ctx . chatId );
const known = record ? . tree ? . nodes . map (( n ) => n . id ) ?? [];
if ( ! known . includes ( nodeId )) {
return (
`nodeId " ${ nodeId } " matches no node in this session's plan map. ` +
( known . length > 0
2026-09-27 02:05:51 +03:00
? `Copy one of your plan_init slugs exactly ( ${ known . slice ( 0 , 5 ). join ( ", " ) }${ known . length > 5 ? ", …" : "" } ).`
: "No plan map exists yet — call plan_init before posing quizzes." )
2026-09-25 23:32:13 +03:00
);
}
return null ;
}
2026-09-11 06:44:20 +03:00
/** Build the teach-side quiz tool for one request (exported for tests): the authoring contract
2026-09-25 23:32:13 +03:00
* + the per-session plan-nodeId validation (both run in execute — the
2026-09-11 06:44:20 +03:00
* grading contract is untouched). Reads the LIVE chat record, so rewrites
* mid-turn are honored, and with no tree yet every nodeId is rejected.
2026-09-27 02:05:51 +03:00
* nodeId is REQUIRED (owner ruling 2026-09-26): the untagged ad-hoc quiz is
* gone — off-map checks route through ask_user_question, and the teach
* seam (first tagged quiz) is structurally guaranteed. */
2026-09-25 23:32:13 +03:00
export function teachQuizToolFor ( ctx : Pick < PlanToolContext , "chatId" | "chatStore" >) : AgentTool < typeof quizInputObject > {
return {
2026-09-12 02:34:18 +03:00
name : "quiz" ,
2026-09-25 23:32:13 +03:00
label : "quiz" ,
2026-09-11 06:44:20 +03:00
description :
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
"can show an explanation. Use it to (1) confirm a taught node actually landed, or (2) probe understanding " +
2026-09-27 02:05:51 +03:00
"whenever you're unsure. Set `nodeId` (required) to the plan node this question is about — the lesson's " +
"position on the map derives from those tags; a node takes as many questions as it deserves, so don't rush " +
"forward. Off-map checks use ask_user_question. Options-only: single-select or multi-select, " +
2026-09-11 06:44:20 +03:00
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
2026-09-27 08:55:13 +03:00
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next. " +
QUIZ_OPTION_CRAFT ,
2026-09-25 23:32:13 +03:00
parameters : quizInputObject ,
2026-09-26 19:08:39 +03:00
prepareArguments : prepareQuestionArguments ,
2026-09-25 23:32:13 +03:00
executionMode : "sequential" ,
execute : async ( _toolCallId , input ) => {
const problems = [... quizInputProblems ( input )];
2026-09-27 02:05:51 +03:00
const nodeIdProblemText = nodeIdProblem ( ctx , input . nodeId );
if ( nodeIdProblemText ) problems . push ( nodeIdProblemText );
2026-09-25 23:32:13 +03:00
if ( problems . length > 0 ) throw new ToolInputError ( "quiz" , problems );
return questionResult ();
},
};
2026-09-09 03:04:16 +03:00
}
2026-09-01 19:15:43 +03:00
export interface TeacherToolContext extends PlanToolContext {
/** Job-tool wiring (sub-agent dispatch): the job store + the runner's
* spawn seam. The composition root injects both. */
jobStore : JobStore ;
/** Fire the background runner for a freshly created job. The tool input
* rides along — the spawn fires mid-stream, before the tool part is
* persisted to the transcript (a transcript scan would race it). */
2026-09-22 16:22:57 +03:00
spawn : ( job : JobRecord , input : ResearchInput | DemoInput | VisualInput | PdfInput ) => void ;
2026-09-22 13:55:01 +03:00
/** Search availability (search port wave, 2026-09-20): the research
* dispatch tool's honest-disable signal — no live engine = the tool
* drops from this registry (JobToolContext.searchAvailable). */
searchAvailable ?: () => boolean ;
2026-08-22 09:06:24 +03:00
}
/**
2026-09-11 06:44:20 +03:00
* Per-request teacher tool registry: teach questions (validated nodeId) +
* plan tools + job tools, all closing over the ctx.
2026-09-19 14:06:07 +03:00
*
* `enabled` is the resolved settings seam (core/pending.ts EnabledTools —
* Platform's chat-handler wiring passes it through from settings). Today
* only the sub-agent tools (research/demo/visual) are user-toggleable, but
* the filtering is GENERIC: any name explicitly false is dropped, so
* future toggleables (question-tool variants, …) need no change here.
* Absent/undefined = enabled — tests and old records see today's set.
2026-08-22 09:06:24 +03:00
*/
2026-09-27 08:55:13 +03:00
/** The quizzing mechanics (owner 2026-09-27): `tools.explain === true` in
* the resolved settings ⇒ free-recall checks (explain armed, quiz dropped)
* — anything else ⇒ multiple choice, exactly today's set. Mechanics-
* managed, NOT individually toggleable: quiz/explain never pass through
* filterEnabledTools (the 2026-09-19 wedge ruling stands — a quiz-less
* prober collapses its assessment flow). */
export function quizMechanics ( enabled? : EnabledTools ) : "choice" | "free" {
return enabled ? . explain === true ? "free" : "choice" ;
}
/** Strip the mechanics keys so filterEnabledTools can never drop the
* quizzing tools (the derivation above is their only switch). */
function withoutMechanics ( enabled? : EnabledTools ) : EnabledTools | undefined {
if ( ! enabled ) return undefined ;
const { explain : _e , quiz : _q , ... rest } = enabled ;
return rest ;
}
2026-09-19 14:06:07 +03:00
export function createTeacherTools (
ctx : TeacherToolContext ,
enabled? : EnabledTools ,
2026-09-25 23:32:13 +03:00
) : Record < string , AgentTool < any >> {
2026-09-27 08:55:13 +03:00
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the teach
// quiz for the teach explain — one quizzing tool at a time, whichever
// the settings arm. ask stays always-on.
const mechanics = quizMechanics ( enabled );
// Same widening as the prober's (see above) — the conditional spread of
// two differently-typed literals collapses into an unusable union type.
const quizzing : Record < string , AgentTool < any >> =
mechanics === "free" ? { explain : teachExplainToolFor ( ctx ) } : { quiz : teachQuizToolFor ( ctx ) };
2026-09-19 14:06:07 +03:00
return filterEnabledTools (
{
2026-09-27 08:55:13 +03:00
... quizzing ,
2026-09-19 14:06:07 +03:00
ask_user_question : askUserQuestionTool ,
2026-09-19 16:50:20 +03:00
// The learner-filesystem set is always-on (user-decided 2026-09,
// sources v1): the teacher locates and reads learner-referenced
// files on its own terms — nothing auto-injects. Read-only by
// construction (fs-shared.ts).
2026-09-19 15:24:26 +03:00
read : readTool ,
2026-09-19 16:50:20 +03:00
ls : lsTool ,
find : findTool ,
grep : grepTool ,
2026-09-19 14:06:07 +03:00
... planTools ( ctx ),
2026-09-22 16:22:57 +03:00
// Fix wave (2026-09-20): searchAvailable was RECEIVED here but
// DROPPED when building the inner JobToolContext — the drop-gate
// always took its ?? true default. Threaded now: the live signal
// reaches jobTools' research gate.
... jobTools ({ chatId : ctx.chatId , jobStore : ctx.jobStore , spawn : ctx.spawn , searchAvailable : ctx.searchAvailable }),
2026-09-19 14:06:07 +03:00
},
2026-09-27 08:55:13 +03:00
withoutMechanics ( enabled ),
2026-09-19 14:06:07 +03:00
);
2026-08-22 09:06:24 +03:00
}