2025-11-18 19:27:11 +05:30
|
|
|
import { AgentState, streamAgent } from "./application/lib/agent.js";
|
2025-10-28 13:17:06 +05:30
|
|
|
import { StreamRenderer } from "./application/lib/stream-renderer.js";
|
2025-11-16 20:58:31 +05:30
|
|
|
import { stdin as input, stdout as output } from "node:process";
|
|
|
|
|
import fs from "fs";
|
|
|
|
|
import path from "path";
|
2025-11-20 16:41:41 +05:30
|
|
|
import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js";
|
2025-11-18 19:27:11 +05:30
|
|
|
import { RunEvent } from "./application/entities/run-events.js";
|
2025-11-16 20:58:31 +05:30
|
|
|
import { createInterface, Interface } from "node:readline/promises";
|
2025-11-18 19:27:11 +05:30
|
|
|
import { ToolCallPart } from "./application/entities/message.js";
|
2025-11-20 17:53:58 +05:30
|
|
|
import { keyof, z } from "zod";
|
|
|
|
|
import { Flavor, ModelConfig } from "./application/entities/models.js";
|
2025-10-28 13:17:06 +05:30
|
|
|
|
2025-11-18 20:38:14 +05:30
|
|
|
export async function updateState(agent: string, runId: string) {
|
|
|
|
|
const state = new AgentState(agent, runId);
|
|
|
|
|
// If running in a TTY, read run events from stdin line-by-line
|
|
|
|
|
if (!input.isTTY) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
|
|
|
try {
|
|
|
|
|
for await (const line of rl) {
|
|
|
|
|
if (line.trim() === "") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const event = RunEvent.parse(JSON.parse(line));
|
|
|
|
|
state.ingestAndLog(event);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
rl.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 22:53:50 +05:30
|
|
|
function renderGreeting() {
|
|
|
|
|
const logo = `
|
|
|
|
|
|
|
|
|
|
$$\\ $$\\
|
|
|
|
|
$$ | $$ |
|
|
|
|
|
$$$$$$\\ $$$$$$\\ $$\\ $$\\ $$\\ $$$$$$$\\ $$$$$$\\ $$$$$$\\ $$$$$$\\ $$\\ $$\\
|
|
|
|
|
$$ __$$\\ $$ __$$\\ $$ | $$ | $$ |$$ __$$\\ $$ __$$\\ \\____$$\\_$$ _| \\$$\\ $$ |
|
|
|
|
|
$$ | \\__|$$ / $$ |$$ | $$ | $$ |$$ | $$ |$$ / $$ | $$$$$$$ | $$ | \\$$$$ /
|
|
|
|
|
$$ | $$ | $$ |$$ | $$ | $$ |$$ | $$ |$$ | $$ |$$ __$$ | $$ |$$\\ $$ $$<
|
|
|
|
|
$$ | \\$$$$$$ |\\$$$$$\\$$$$ |$$$$$$$ |\\$$$$$$ |\\$$$$$$$ | \\$$$$ |$$ /\\$$\\
|
|
|
|
|
\\__| \\______/ \\_____\\____/ \\_______/ \\______/ \\_______| \\____/ \\__/ \\__|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`;
|
|
|
|
|
console.log(logo);
|
|
|
|
|
console.log("\nHow can i help you today?");
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 01:51:22 +05:30
|
|
|
export async function app(opts: {
|
|
|
|
|
agent: string;
|
|
|
|
|
runId?: string;
|
|
|
|
|
input?: string;
|
2025-11-16 20:58:31 +05:30
|
|
|
noInteractive?: boolean;
|
2025-11-15 01:51:22 +05:30
|
|
|
}) {
|
2025-11-20 16:41:41 +05:30
|
|
|
// check if model config is required
|
|
|
|
|
const c = await getModelConfig();
|
|
|
|
|
if (!c) {
|
|
|
|
|
await modelConfig();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:17:06 +05:30
|
|
|
const renderer = new StreamRenderer();
|
2025-11-18 20:38:14 +05:30
|
|
|
const state = new AgentState(opts.agent, opts.runId);
|
2025-11-16 20:58:31 +05:30
|
|
|
|
2025-11-18 22:53:50 +05:30
|
|
|
if (opts.agent === "copilot" && !opts.runId) {
|
|
|
|
|
renderGreeting();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 20:58:31 +05:30
|
|
|
// load existing and assemble state if required
|
|
|
|
|
let runId = opts.runId;
|
|
|
|
|
if (runId) {
|
|
|
|
|
console.error("loading run", runId);
|
|
|
|
|
let stream: fs.ReadStream | null = null;
|
|
|
|
|
let rl: Interface | null = null;
|
|
|
|
|
try {
|
|
|
|
|
const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
|
|
|
|
|
stream = fs.createReadStream(logFile, { encoding: "utf8" });
|
|
|
|
|
rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
|
|
|
for await (const line of rl) {
|
|
|
|
|
if (line.trim() === "") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const parsed = JSON.parse(line);
|
|
|
|
|
const event = RunEvent.parse(parsed);
|
2025-11-18 19:27:11 +05:30
|
|
|
state.ingest(event);
|
2025-11-16 20:58:31 +05:30
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
stream?.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let rl: Interface | null = null;
|
|
|
|
|
if (!opts.noInteractive) {
|
|
|
|
|
rl = createInterface({ input, output });
|
|
|
|
|
}
|
2025-11-18 20:38:14 +05:30
|
|
|
let inputConsumed = false;
|
2025-11-18 19:27:11 +05:30
|
|
|
|
2025-11-16 20:58:31 +05:30
|
|
|
try {
|
|
|
|
|
while (true) {
|
2025-11-18 19:27:11 +05:30
|
|
|
// ask for pending tool permissions
|
|
|
|
|
for (const perm of Object.values(state.getPendingPermissions())) {
|
2025-11-18 20:38:14 +05:30
|
|
|
if (opts.noInteractive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
const response = await getToolCallPermission(perm.toolCall, rl!);
|
|
|
|
|
state.ingestAndLog({
|
|
|
|
|
type: "tool-permission-response",
|
|
|
|
|
response,
|
|
|
|
|
toolCallId: perm.toolCall.toolCallId,
|
|
|
|
|
subflow: perm.subflow,
|
|
|
|
|
});
|
2025-11-16 20:58:31 +05:30
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
|
|
|
|
|
// ask for pending human input
|
|
|
|
|
for (const ask of Object.values(state.getPendingAskHumans())) {
|
2025-11-18 20:38:14 +05:30
|
|
|
if (opts.noInteractive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
const response = await getAskHumanResponse(ask.query, rl!);
|
|
|
|
|
state.ingestAndLog({
|
|
|
|
|
type: "ask-human-response",
|
|
|
|
|
response,
|
|
|
|
|
toolCallId: ask.toolCallId,
|
|
|
|
|
subflow: ask.subflow,
|
|
|
|
|
});
|
2025-11-16 20:58:31 +05:30
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
|
|
|
|
|
// run one turn
|
|
|
|
|
for await (const event of streamAgent(state)) {
|
2025-11-16 20:58:31 +05:30
|
|
|
renderer.render(event);
|
|
|
|
|
if (event?.type === "error") {
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:27:11 +05:30
|
|
|
// if nothing pending, get user input
|
|
|
|
|
if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) {
|
2025-11-18 20:38:14 +05:30
|
|
|
if (opts.input && !inputConsumed) {
|
|
|
|
|
state.ingestAndLog({
|
|
|
|
|
type: "message",
|
|
|
|
|
message: {
|
|
|
|
|
role: "user",
|
|
|
|
|
content: opts.input,
|
|
|
|
|
},
|
|
|
|
|
subflow: [],
|
|
|
|
|
});
|
|
|
|
|
inputConsumed = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (opts.noInteractive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
const response = await getUserInput(rl!);
|
|
|
|
|
state.ingestAndLog({
|
|
|
|
|
type: "message",
|
|
|
|
|
message: {
|
|
|
|
|
role: "user",
|
|
|
|
|
content: response,
|
|
|
|
|
},
|
|
|
|
|
subflow: [],
|
|
|
|
|
});
|
2025-11-16 20:58:31 +05:30
|
|
|
}
|
2025-11-11 12:32:46 +05:30
|
|
|
}
|
2025-11-16 20:58:31 +05:30
|
|
|
} finally {
|
|
|
|
|
rl?.close();
|
2025-10-28 13:17:06 +05:30
|
|
|
}
|
2025-11-18 02:28:49 +05:30
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:27:11 +05:30
|
|
|
async function getToolCallPermission(
|
|
|
|
|
call: z.infer<typeof ToolCallPart>,
|
|
|
|
|
rl: Interface,
|
|
|
|
|
): Promise<"approve" | "deny"> {
|
|
|
|
|
const question = `Do you want to allow running the following tool: ${call.toolName}?:
|
|
|
|
|
|
|
|
|
|
Tool name: ${call.toolName}
|
|
|
|
|
Tool arguments: ${JSON.stringify(call.arguments)}
|
|
|
|
|
|
|
|
|
|
Choices: y/n/a/d:
|
|
|
|
|
- y: approve
|
|
|
|
|
- n: deny
|
|
|
|
|
`;
|
|
|
|
|
const input = await rl.question(question);
|
|
|
|
|
if (input.toLowerCase() === "y") return "approve";
|
|
|
|
|
if (input.toLowerCase() === "n") return "deny";
|
|
|
|
|
return "deny";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getAskHumanResponse(
|
|
|
|
|
query: string,
|
|
|
|
|
rl: Interface,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const input = await rl.question(`The agent is asking for your help with the following query:
|
|
|
|
|
|
|
|
|
|
Question: ${query}
|
|
|
|
|
|
|
|
|
|
Please respond to the question.
|
|
|
|
|
`);
|
|
|
|
|
return input;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getUserInput(
|
|
|
|
|
rl: Interface,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const input = await rl.question("You: ");
|
|
|
|
|
if (["quit", "exit", "q"].includes(input.toLowerCase().trim())) {
|
|
|
|
|
console.error("Bye!");
|
|
|
|
|
process.exit(0);
|
2025-11-18 02:28:49 +05:30
|
|
|
}
|
2025-11-18 19:27:11 +05:30
|
|
|
return input;
|
2025-11-20 16:41:41 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function modelConfig() {
|
|
|
|
|
// load existing model config
|
|
|
|
|
const config = await getModelConfig();
|
|
|
|
|
|
|
|
|
|
const rl = createInterface({ input, output });
|
|
|
|
|
try {
|
2025-11-20 17:53:58 +05:30
|
|
|
const defaultApiKeyEnvVars: Record<z.infer<typeof Flavor>, string> = {
|
|
|
|
|
openai: "OPENAI_API_KEY",
|
|
|
|
|
anthropic: "ANTHROPIC_API_KEY",
|
|
|
|
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
|
|
|
ollama: "",
|
|
|
|
|
"openai-compatible": "",
|
|
|
|
|
openrouter: "",
|
|
|
|
|
};
|
|
|
|
|
const defaultBaseUrls: Record<z.infer<typeof Flavor>, string> = {
|
2025-11-20 16:41:41 +05:30
|
|
|
openai: "https://api.openai.com/v1",
|
|
|
|
|
anthropic: "https://api.anthropic.com/v1",
|
2025-11-20 16:44:34 +05:30
|
|
|
google: "https://generativelanguage.googleapis.com/v1beta",
|
2025-11-20 16:41:41 +05:30
|
|
|
ollama: "http://localhost:11434",
|
|
|
|
|
"openai-compatible": "http://localhost:8080/v1",
|
|
|
|
|
openrouter: "https://openrouter.ai/api/v1",
|
|
|
|
|
};
|
2025-11-20 17:53:58 +05:30
|
|
|
const defaultModels: Record<z.infer<typeof Flavor>, string> = {
|
2025-11-20 16:41:41 +05:30
|
|
|
openai: "gpt-5.1",
|
2025-11-20 17:02:54 +05:30
|
|
|
anthropic: "claude-sonnet-4-5",
|
|
|
|
|
google: "gemini-2.5-pro",
|
2025-11-20 16:41:41 +05:30
|
|
|
ollama: "llama3.1",
|
2025-11-20 17:02:54 +05:30
|
|
|
"openai-compatible": "openai/gpt-5.1",
|
2025-11-20 16:41:41 +05:30
|
|
|
openrouter: "openrouter/auto",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const currentProvider = config?.defaults?.provider;
|
|
|
|
|
const currentModel = config?.defaults?.model;
|
|
|
|
|
const currentProviderConfig = currentProvider ? config?.providers?.[currentProvider] : undefined;
|
|
|
|
|
if (config) {
|
2025-11-20 17:02:54 +05:30
|
|
|
renderCurrentModel(currentProvider || "none", currentProviderConfig?.flavor || "", currentModel || "none");
|
2025-11-20 16:41:41 +05:30
|
|
|
}
|
|
|
|
|
|
2025-11-20 17:53:58 +05:30
|
|
|
const FlavorList = [...Flavor.options];
|
|
|
|
|
const flavorPromptLines = FlavorList
|
2025-11-20 16:41:41 +05:30
|
|
|
.map((f, idx) => ` ${idx + 1}. ${f}`)
|
|
|
|
|
.join("\n");
|
|
|
|
|
const flavorAnswer = await rl.question(
|
2025-11-20 17:53:58 +05:30
|
|
|
`Select a provider type:\n${flavorPromptLines}\nEnter number or name: `
|
2025-11-20 16:41:41 +05:30
|
|
|
);
|
|
|
|
|
let selectedFlavorRaw = flavorAnswer.trim();
|
2025-11-20 17:53:58 +05:30
|
|
|
let selectedFlavor: z.infer<typeof Flavor> | null = null;
|
|
|
|
|
if (/^\d+$/.test(selectedFlavorRaw)) {
|
2025-11-20 16:41:41 +05:30
|
|
|
const idx = parseInt(selectedFlavorRaw, 10) - 1;
|
2025-11-20 17:53:58 +05:30
|
|
|
if (idx >= 0 && idx < FlavorList.length) {
|
|
|
|
|
selectedFlavor = FlavorList[idx];
|
2025-11-20 16:41:41 +05:30
|
|
|
}
|
2025-11-20 17:53:58 +05:30
|
|
|
} else if (FlavorList.includes(selectedFlavorRaw as z.infer<typeof Flavor>)) {
|
|
|
|
|
selectedFlavor = selectedFlavorRaw as z.infer<typeof Flavor>;
|
2025-11-20 16:41:41 +05:30
|
|
|
}
|
|
|
|
|
if (!selectedFlavor) {
|
|
|
|
|
console.error("Invalid selection. Exiting.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingAliases = Object.keys(config?.providers || {}).filter(
|
|
|
|
|
(name) => config?.providers?.[name]?.flavor === selectedFlavor,
|
|
|
|
|
);
|
|
|
|
|
let providerName: string | null = null;
|
|
|
|
|
let chooseMode: "existing" | "add" = "add";
|
|
|
|
|
if (existingAliases.length > 0) {
|
|
|
|
|
const listLines = existingAliases
|
|
|
|
|
.map((alias, idx) => ` ${idx + 1}. use existing: ${alias}`)
|
|
|
|
|
.join("\n");
|
|
|
|
|
const addIndex = existingAliases.length + 1;
|
|
|
|
|
const providerSelect = await rl.question(
|
|
|
|
|
`Found existing providers for ${selectedFlavor}:\n${listLines}\n ${addIndex}. add new\nEnter number or name/alias [${addIndex}]: `,
|
|
|
|
|
);
|
|
|
|
|
const sel = providerSelect.trim();
|
|
|
|
|
if (sel === "" || sel.toLowerCase() === "add" || sel.toLowerCase() === "new") {
|
|
|
|
|
chooseMode = "add";
|
|
|
|
|
} else if (/^\d+$/.test(sel)) {
|
|
|
|
|
const idx = parseInt(sel, 10) - 1;
|
|
|
|
|
if (idx >= 0 && idx < existingAliases.length) {
|
|
|
|
|
providerName = existingAliases[idx];
|
|
|
|
|
chooseMode = "existing";
|
|
|
|
|
} else if (idx === existingAliases.length) {
|
|
|
|
|
chooseMode = "add";
|
|
|
|
|
} else {
|
|
|
|
|
console.error("Invalid selection. Exiting.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if (existingAliases.includes(sel)) {
|
|
|
|
|
providerName = sel;
|
|
|
|
|
chooseMode = "existing";
|
|
|
|
|
} else {
|
|
|
|
|
console.error("Invalid selection. Exiting.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (chooseMode === "existing" && !providerName) {
|
|
|
|
|
console.error("No provider selected. Exiting.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (chooseMode === "existing") {
|
|
|
|
|
const modelDefault =
|
|
|
|
|
currentProvider === providerName && currentModel
|
|
|
|
|
? currentModel
|
|
|
|
|
: defaultModels[selectedFlavor];
|
|
|
|
|
const modelAns = await rl.question(
|
|
|
|
|
`Specify model for ${selectedFlavor} [${modelDefault}]: `,
|
|
|
|
|
);
|
|
|
|
|
const model = modelAns.trim() || modelDefault;
|
|
|
|
|
|
|
|
|
|
const newConfig = {
|
|
|
|
|
providers: { ...(config?.providers || {}) },
|
|
|
|
|
defaults: {
|
|
|
|
|
provider: providerName!,
|
|
|
|
|
model,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
await updateModelConfig(newConfig as any);
|
|
|
|
|
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 17:53:58 +05:30
|
|
|
const headers: Record<string, string> = {};
|
|
|
|
|
|
2025-11-20 16:41:41 +05:30
|
|
|
const providerNameAns = await rl.question(
|
|
|
|
|
`Enter a name/alias for this provider [${selectedFlavor}]: `,
|
|
|
|
|
);
|
|
|
|
|
providerName = providerNameAns.trim() || selectedFlavor;
|
|
|
|
|
|
|
|
|
|
const baseUrlAns = await rl.question(
|
2025-11-20 17:53:58 +05:30
|
|
|
`Enter baseURL for ${selectedFlavor} [${defaultBaseUrls[selectedFlavor]}]: `,
|
2025-11-20 16:41:41 +05:30
|
|
|
);
|
2025-11-20 17:53:58 +05:30
|
|
|
const baseURL = baseUrlAns.trim() || undefined;
|
2025-11-20 16:41:41 +05:30
|
|
|
|
2025-11-20 17:53:58 +05:30
|
|
|
let apiKey: string | undefined = undefined;
|
|
|
|
|
if (selectedFlavor !== "ollama") {
|
|
|
|
|
let autopickText = "";
|
|
|
|
|
if (defaultApiKeyEnvVars[selectedFlavor]) {
|
|
|
|
|
autopickText = ` (leave blank to pick from environment variable ${defaultApiKeyEnvVars[selectedFlavor]})`;
|
|
|
|
|
}
|
|
|
|
|
const apiKeyAns = await rl.question(
|
|
|
|
|
`Enter API key for ${selectedFlavor}${autopickText}: `,
|
|
|
|
|
);
|
|
|
|
|
apiKey = apiKeyAns.trim() || undefined;
|
|
|
|
|
}
|
|
|
|
|
if (selectedFlavor === "ollama") {
|
|
|
|
|
const keyAns = await rl.question(
|
|
|
|
|
`Enter API key for ${selectedFlavor} (optional): `
|
|
|
|
|
);
|
|
|
|
|
const key = keyAns.trim();
|
|
|
|
|
if (key) {
|
|
|
|
|
headers["Authorization"] = `Bearer ${key}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-20 16:41:41 +05:30
|
|
|
|
|
|
|
|
const modelDefault = defaultModels[selectedFlavor];
|
|
|
|
|
const modelAns = await rl.question(
|
|
|
|
|
`Specify model for ${selectedFlavor} [${modelDefault}]: `,
|
|
|
|
|
);
|
|
|
|
|
const model = modelAns.trim() || modelDefault;
|
|
|
|
|
|
|
|
|
|
const mergedProviders = {
|
|
|
|
|
...(config?.providers || {}),
|
|
|
|
|
[providerName]: {
|
|
|
|
|
flavor: selectedFlavor,
|
|
|
|
|
...(apiKey ? { apiKey } : {}),
|
|
|
|
|
...(baseURL ? { baseURL } : {}),
|
2025-11-20 17:53:58 +05:30
|
|
|
...(headers ? { headers } : {}),
|
2025-11-20 16:41:41 +05:30
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const newConfig = {
|
|
|
|
|
providers: mergedProviders,
|
|
|
|
|
defaults: {
|
|
|
|
|
provider: providerName,
|
|
|
|
|
model,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await updateModelConfig(newConfig as any);
|
2025-11-20 17:02:54 +05:30
|
|
|
renderCurrentModel(providerName, selectedFlavor, model);
|
|
|
|
|
console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`);
|
2025-11-20 16:41:41 +05:30
|
|
|
} finally {
|
|
|
|
|
rl.close();
|
|
|
|
|
}
|
2025-11-20 17:02:54 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCurrentModel(provider: string, flavor: string, model: string) {
|
|
|
|
|
console.log("Currently using:");
|
|
|
|
|
console.log(`- provider: ${provider}${flavor ? ` (${flavor})` : ""}`);
|
|
|
|
|
console.log(`- model: ${model}`);
|
|
|
|
|
console.log("");
|
2025-11-15 01:51:22 +05:30
|
|
|
}
|