thinyai 0.1.10 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +92 -29
- package/package.json +7 -7
package/dist/bin.js
CHANGED
|
@@ -164,7 +164,7 @@ var SlashPrompt = class {
|
|
|
164
164
|
|
|
165
165
|
// src/main.ts
|
|
166
166
|
import { stdin, stdout } from "process";
|
|
167
|
-
import { mkdirSync as
|
|
167
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
168
168
|
import { homedir as homedir2 } from "os";
|
|
169
169
|
import { join as join2 } from "path";
|
|
170
170
|
import { z as z7 } from "zod";
|
|
@@ -1182,6 +1182,7 @@ function memwalFactsPlugin(opts) {
|
|
|
1182
1182
|
|
|
1183
1183
|
// ../../packages/adapters/walrus/src/index.ts
|
|
1184
1184
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
1185
|
+
import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2 } from "fs";
|
|
1185
1186
|
import { dirname } from "path";
|
|
1186
1187
|
import { z as z2 } from "zod";
|
|
1187
1188
|
var DEFAULT_PUBLISHER = "https://publisher.walrus-testnet.walrus.space";
|
|
@@ -1319,6 +1320,13 @@ function walrusMemoryPlugin(opts) {
|
|
|
1319
1320
|
let pending = Promise.resolve();
|
|
1320
1321
|
async function load() {
|
|
1321
1322
|
if (cache) return cache;
|
|
1323
|
+
if (opts.cacheFile && existsSync2(opts.cacheFile)) {
|
|
1324
|
+
try {
|
|
1325
|
+
cache = JSON.parse(readFileSync2(opts.cacheFile, "utf8"));
|
|
1326
|
+
return cache;
|
|
1327
|
+
} catch {
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1322
1330
|
loading ??= (async () => {
|
|
1323
1331
|
try {
|
|
1324
1332
|
const blobId = await opts.pointers.get(key);
|
|
@@ -1334,6 +1342,13 @@ function walrusMemoryPlugin(opts) {
|
|
|
1334
1342
|
}
|
|
1335
1343
|
function save(facts) {
|
|
1336
1344
|
cache = facts;
|
|
1345
|
+
if (opts.cacheFile) {
|
|
1346
|
+
try {
|
|
1347
|
+
mkdirSync(dirname(opts.cacheFile), { recursive: true });
|
|
1348
|
+
writeFileSync(opts.cacheFile, JSON.stringify(facts, null, 2));
|
|
1349
|
+
} catch {
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1337
1352
|
opts.onStoreStart?.();
|
|
1338
1353
|
pending = pending.then(async () => {
|
|
1339
1354
|
try {
|
|
@@ -1520,18 +1535,13 @@ function explorerTxUrl(network, digest) {
|
|
|
1520
1535
|
function suiPlugin(opts) {
|
|
1521
1536
|
const sig = opts.signer;
|
|
1522
1537
|
const resolve2 = typeof sig === "function" ? sig : () => sig;
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
"No Sui wallet configured yet. Call sui_setup to create or import one (or tell the user to run `thiny sui init`)."
|
|
1528
|
-
);
|
|
1529
|
-
}
|
|
1530
|
-
return s;
|
|
1538
|
+
const SETUP_NEEDED = {
|
|
1539
|
+
ok: false,
|
|
1540
|
+
setupNeeded: true,
|
|
1541
|
+
message: "Sui isn't set up yet. Ask the user which network (testnet or mainnet) and which wallet (generate a new key, import a private key, or use a Rill agent wallet), then call sui_setup. Do NOT retry this tool until setup is complete."
|
|
1531
1542
|
};
|
|
1532
1543
|
const requireSimSuccess = opts.policy?.requireSimSuccess ?? true;
|
|
1533
|
-
const executeTx = async (tx, toolName, approvalArgs, reason) => {
|
|
1534
|
-
const signer = signerOrThrow();
|
|
1544
|
+
const executeTx = async (signer, tx, toolName, approvalArgs, reason) => {
|
|
1535
1545
|
const sim = await signer.devInspect(tx);
|
|
1536
1546
|
const status = sim.effects.status.status;
|
|
1537
1547
|
if (requireSimSuccess && status !== "success") {
|
|
@@ -1561,10 +1571,11 @@ function suiPlugin(opts) {
|
|
|
1561
1571
|
coinType: z4.string().optional().describe("Coin type, e.g. 0x2::sui::SUI (default: SUI).")
|
|
1562
1572
|
}),
|
|
1563
1573
|
execute: async ({ address, coinType }) => {
|
|
1564
|
-
const signer =
|
|
1574
|
+
const signer = resolve2();
|
|
1575
|
+
if (!signer) return SETUP_NEEDED;
|
|
1565
1576
|
const owner = address ?? signer.address;
|
|
1566
1577
|
if (owner === void 0) {
|
|
1567
|
-
|
|
1578
|
+
return { ok: false, message: "No address given and no wallet is set up. Run sui_setup first." };
|
|
1568
1579
|
}
|
|
1569
1580
|
const bal = await signer.client.getBalance({ owner, ...coinType ? { coinType } : {} });
|
|
1570
1581
|
return {
|
|
@@ -1580,7 +1591,8 @@ function suiPlugin(opts) {
|
|
|
1580
1591
|
description: "Read a Sui object's type and fields by id.",
|
|
1581
1592
|
parameters: z4.object({ objectId: z4.string().min(1).describe("The object id (0x\u2026).") }),
|
|
1582
1593
|
execute: async ({ objectId }) => {
|
|
1583
|
-
const signer =
|
|
1594
|
+
const signer = resolve2();
|
|
1595
|
+
if (!signer) return SETUP_NEEDED;
|
|
1584
1596
|
const res = await signer.client.getObject({
|
|
1585
1597
|
id: objectId,
|
|
1586
1598
|
options: { showContent: true, showType: true, showOwner: true }
|
|
@@ -1602,8 +1614,10 @@ function suiPlugin(opts) {
|
|
|
1602
1614
|
unsignedTx: z4.string().min(1).describe("Unsigned PTB \u2014 the JSON string from Transaction.toJSON() (no sender, no gas).")
|
|
1603
1615
|
}),
|
|
1604
1616
|
execute: async ({ unsignedTx }) => {
|
|
1617
|
+
const signer = resolve2();
|
|
1618
|
+
if (!signer) return SETUP_NEEDED;
|
|
1605
1619
|
const tx = Transaction2.from(unsignedTx);
|
|
1606
|
-
return await executeTx(tx, "sui_execute_ptb", { unsignedTx }, "sign and submit a Sui PTB");
|
|
1620
|
+
return await executeTx(signer, tx, "sui_execute_ptb", { unsignedTx }, "sign and submit a Sui PTB");
|
|
1607
1621
|
}
|
|
1608
1622
|
});
|
|
1609
1623
|
const transfer = defineTool({
|
|
@@ -1616,7 +1630,8 @@ function suiPlugin(opts) {
|
|
|
1616
1630
|
coinType: z4.string().optional().describe("Coin type (default 0x2::sui::SUI).")
|
|
1617
1631
|
}),
|
|
1618
1632
|
execute: async ({ recipient, amountMist, coinType }) => {
|
|
1619
|
-
const signer =
|
|
1633
|
+
const signer = resolve2();
|
|
1634
|
+
if (!signer) return SETUP_NEEDED;
|
|
1620
1635
|
const amount = BigInt(amountMist);
|
|
1621
1636
|
const type = coinType ?? "0x2::sui::SUI";
|
|
1622
1637
|
const tx = new Transaction2();
|
|
@@ -1625,7 +1640,7 @@ function suiPlugin(opts) {
|
|
|
1625
1640
|
tx.transferObjects([coin], tx.pure.address(recipient));
|
|
1626
1641
|
} else {
|
|
1627
1642
|
const owner = signer.address;
|
|
1628
|
-
if (owner === void 0)
|
|
1643
|
+
if (owner === void 0) return { ok: false, message: "Wallet has no address. Run sui_setup." };
|
|
1629
1644
|
const { data } = await signer.client.getCoins({ owner, coinType: type });
|
|
1630
1645
|
const [first, ...rest] = data;
|
|
1631
1646
|
if (!first) throw new Error(`sui_transfer: no ${type} coins owned by ${owner}.`);
|
|
@@ -1637,6 +1652,7 @@ function suiPlugin(opts) {
|
|
|
1637
1652
|
tx.transferObjects([coin], tx.pure.address(recipient));
|
|
1638
1653
|
}
|
|
1639
1654
|
return executeTx(
|
|
1655
|
+
signer,
|
|
1640
1656
|
tx,
|
|
1641
1657
|
"sui_transfer",
|
|
1642
1658
|
{ recipient, amountMist, coinType: type },
|
|
@@ -1660,6 +1676,8 @@ function suiPlugin(opts) {
|
|
|
1660
1676
|
).optional().describe("Ordered arguments to the function.")
|
|
1661
1677
|
}),
|
|
1662
1678
|
execute: async ({ target, typeArguments, args }) => {
|
|
1679
|
+
const signer = resolve2();
|
|
1680
|
+
if (!signer) return SETUP_NEEDED;
|
|
1663
1681
|
const tx = new Transaction2();
|
|
1664
1682
|
const built = (args ?? []).map((a) => {
|
|
1665
1683
|
if (a.kind === "gas") return tx.gas;
|
|
@@ -1690,7 +1708,7 @@ function suiPlugin(opts) {
|
|
|
1690
1708
|
}
|
|
1691
1709
|
});
|
|
1692
1710
|
tx.moveCall({ target, typeArguments: typeArguments ?? [], arguments: built });
|
|
1693
|
-
return await executeTx(tx, "sui_move_call", { target, typeArguments, args }, `Move call ${target}`);
|
|
1711
|
+
return await executeTx(signer, tx, "sui_move_call", { target, typeArguments, args }, `Move call ${target}`);
|
|
1694
1712
|
}
|
|
1695
1713
|
});
|
|
1696
1714
|
return { name: "sui", tools: [balance, object, executePtb, transfer, moveCall] };
|
|
@@ -1956,7 +1974,7 @@ var SkillRegistry = class {
|
|
|
1956
1974
|
var defaultRegistry = new SkillRegistry();
|
|
1957
1975
|
|
|
1958
1976
|
// src/onboarding.ts
|
|
1959
|
-
import { existsSync as
|
|
1977
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, chmodSync } from "fs";
|
|
1960
1978
|
import { homedir } from "os";
|
|
1961
1979
|
import { join, dirname as dirname2 } from "path";
|
|
1962
1980
|
import { fileURLToPath } from "url";
|
|
@@ -1966,18 +1984,18 @@ var CONFIG = join(THINY_DIR, "config.json");
|
|
|
1966
1984
|
function version() {
|
|
1967
1985
|
try {
|
|
1968
1986
|
const pkg = join(dirname2(fileURLToPath(import.meta.url)), "../package.json");
|
|
1969
|
-
return JSON.parse(
|
|
1987
|
+
return JSON.parse(readFileSync3(pkg, "utf8")).version ?? "0.0.0";
|
|
1970
1988
|
} catch {
|
|
1971
1989
|
return "0.0.0";
|
|
1972
1990
|
}
|
|
1973
1991
|
}
|
|
1974
1992
|
function loadConfig() {
|
|
1975
|
-
return
|
|
1993
|
+
return existsSync3(CONFIG) ? JSON.parse(readFileSync3(CONFIG, "utf8")) : null;
|
|
1976
1994
|
}
|
|
1977
1995
|
function saveConfig(cfg) {
|
|
1978
|
-
|
|
1996
|
+
mkdirSync2(THINY_DIR, { recursive: true });
|
|
1979
1997
|
chmodSync(THINY_DIR, 448);
|
|
1980
|
-
|
|
1998
|
+
writeFileSync2(CONFIG, JSON.stringify(cfg, null, 2));
|
|
1981
1999
|
chmodSync(CONFIG, 384);
|
|
1982
2000
|
}
|
|
1983
2001
|
function applyConfig(cfg) {
|
|
@@ -2538,19 +2556,19 @@ function notifyIfUpdate(thinyDir) {
|
|
|
2538
2556
|
const cur = version();
|
|
2539
2557
|
const cacheFile = join2(thinyDir, "update-check.json");
|
|
2540
2558
|
try {
|
|
2541
|
-
const cached = JSON.parse(
|
|
2559
|
+
const cached = JSON.parse(readFileSync4(cacheFile, "utf8"));
|
|
2542
2560
|
if (cached.latest && isNewerVersion(cached.latest, cur)) {
|
|
2543
2561
|
renderInfo(`Update available: ${cur} \u2192 ${cached.latest} \u2014 run \`thiny update\``);
|
|
2544
2562
|
}
|
|
2545
2563
|
} catch {
|
|
2546
2564
|
}
|
|
2547
2565
|
void fetch("https://registry.npmjs.org/thinyai/latest").then((r) => r.json()).then((j) => {
|
|
2548
|
-
if (j.version)
|
|
2566
|
+
if (j.version) writeFileSync3(cacheFile, JSON.stringify({ latest: j.version, at: Date.now() }));
|
|
2549
2567
|
}).catch(() => void 0);
|
|
2550
2568
|
}
|
|
2551
2569
|
async function runCli() {
|
|
2552
2570
|
const thinyDir = join2(homedir2(), ".thiny");
|
|
2553
|
-
|
|
2571
|
+
mkdirSync3(thinyDir, { recursive: true });
|
|
2554
2572
|
const envLogFile = process.env.THINY_LOG_FILE?.trim();
|
|
2555
2573
|
const logFile = envLogFile && envLogFile.length > 0 ? envLogFile : join2(thinyDir, "cli.log");
|
|
2556
2574
|
const fileLogger = pinoLogger({ level: process.env.LOG_LEVEL ?? "info", file: logFile });
|
|
@@ -2598,6 +2616,8 @@ async function runCli() {
|
|
|
2598
2616
|
// directory `thiny` is launched from — a cwd-relative file would fragment per folder.
|
|
2599
2617
|
pointers: filePointerStore(process.env.WALRUS_POINTERS ?? join2(thinyDir, "thiny-pointers.json")),
|
|
2600
2618
|
userId,
|
|
2619
|
+
// Instant, reliable local mirror — cross-session memory no longer waits on the slow Walrus PUT.
|
|
2620
|
+
cacheFile: join2(thinyDir, `memory-${userId}.json`),
|
|
2601
2621
|
onStoreStart: () => pendingWrites += 1,
|
|
2602
2622
|
onStore: (ref) => {
|
|
2603
2623
|
if (pendingWrites > 0) pendingWrites -= 1;
|
|
@@ -2779,12 +2799,55 @@ async function runCli() {
|
|
|
2779
2799
|
return { activeAddress: address, note: `Now signing as ${address}.` };
|
|
2780
2800
|
}
|
|
2781
2801
|
});
|
|
2802
|
+
const suiBalancesTool = defineTool({
|
|
2803
|
+
name: "sui_balances",
|
|
2804
|
+
description: "Fetch ALL coin balances across ALL of the user's Sui addresses on a network. Use for 'what's my balance / what coins do I have'. Returns each address with its coins. SUI amounts are also given in whole SUI (1 SUI = 1e9 MIST).",
|
|
2805
|
+
parameters: z7.object({
|
|
2806
|
+
network: z7.enum(["testnet", "mainnet"]).optional().describe("Network (default: the active one)."),
|
|
2807
|
+
address: z7.string().optional().describe("Limit to one address (default: all the user's wallets).")
|
|
2808
|
+
}),
|
|
2809
|
+
execute: async ({ network: network2, address }) => {
|
|
2810
|
+
const net = network2 ?? suiNetwork;
|
|
2811
|
+
const client = net === suiNetwork && suiSignerRef ? suiSignerRef.client : suiSigner({ network: net }).client;
|
|
2812
|
+
const walletAddrs = suiWalletsOf(loadConfig()).map((w) => w.address);
|
|
2813
|
+
if (suiSignerRef?.address && !walletAddrs.includes(suiSignerRef.address)) {
|
|
2814
|
+
walletAddrs.push(suiSignerRef.address);
|
|
2815
|
+
}
|
|
2816
|
+
const addrs = address ? [address] : walletAddrs;
|
|
2817
|
+
if (addrs.length === 0) {
|
|
2818
|
+
return {
|
|
2819
|
+
ok: false,
|
|
2820
|
+
setupNeeded: true,
|
|
2821
|
+
message: "Sui isn't set up. Ask the user which network and wallet (generate / import / Rill), then call sui_setup. Don't retry until then."
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
const addresses = [];
|
|
2825
|
+
for (const addr of addrs) {
|
|
2826
|
+
const balances = await client.getAllBalances({ owner: addr });
|
|
2827
|
+
addresses.push({
|
|
2828
|
+
address: addr,
|
|
2829
|
+
coins: balances.map((b) => {
|
|
2830
|
+
const symbol = b.coinType.split("::").pop() ?? b.coinType;
|
|
2831
|
+
const isSui = b.coinType.endsWith("::sui::SUI");
|
|
2832
|
+
return {
|
|
2833
|
+
symbol,
|
|
2834
|
+
coinType: b.coinType,
|
|
2835
|
+
balanceMist: b.totalBalance,
|
|
2836
|
+
...isSui ? { sui: Number(b.totalBalance) / 1e9 } : {}
|
|
2837
|
+
};
|
|
2838
|
+
})
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
return { network: net, addresses };
|
|
2842
|
+
}
|
|
2843
|
+
});
|
|
2782
2844
|
const walletTools = [
|
|
2783
2845
|
suiWalletsTool,
|
|
2784
2846
|
suiCreateWalletTool,
|
|
2785
2847
|
suiImportWalletTool,
|
|
2786
2848
|
suiExportWalletTool,
|
|
2787
|
-
suiUseWalletTool
|
|
2849
|
+
suiUseWalletTool,
|
|
2850
|
+
suiBalancesTool
|
|
2788
2851
|
];
|
|
2789
2852
|
const fetchUrlTool = defineTool({
|
|
2790
2853
|
name: "fetch_url",
|
|
@@ -2855,9 +2918,9 @@ async function runCli() {
|
|
|
2855
2918
|
HOW TO ACT: When a request maps to one of your tools, CALL THE TOOL automatically \u2014 figure out the right tool yourself; do not ask the user which tool to run, do not ask permission for read-only actions, and never say you can't do something one of your tools covers. Chain tools when needed (e.g. web_search \u2192 fetch_url \u2192 act).
|
|
2856
2919
|
|
|
2857
2920
|
YOUR TOOLS:
|
|
2858
|
-
\u2022 Memory \u2014 remember_fact
|
|
2921
|
+
\u2022 Memory \u2014 remember_fact: durable memory across sessions. Whenever the user shares anything durable about themselves (name, role, preferences, projects, goals), call remember_fact ONCE to save it. Your known facts are AUTO-INJECTED at the top of every conversation under \u201C[User Memory \u2026]\u201D, so answer \u201Cwhat do you remember / what's my name\u201D directly from that context \u2014 do NOT call recall_memory unless the injected memory is empty and you truly need to re-check. You DO remember across sessions; never say otherwise.
|
|
2859
2922
|
\u2022 Links \u2014 fetch_url: read ANY URL the user shares (a skill.md, docs, JSON, an API/MCP endpoint). Always fetch shared links instead of saying you can't open URLs.
|
|
2860
|
-
` + (webSearchOn ? "\u2022 Web search \u2014 web_search: search the web for anything you don't know (news, prices, docs). web_search FINDS pages by query; fetch_url READS a specific URL \u2014 use them together.\n" : "") + "\u2022 Planning \u2014 update_plan (track multi-step work), delegate_task (hand a focused subtask to a sub-agent).\n\u2022 Sui blockchain \u2014 you transact yourself; NEVER tell the user to install a browser wallet. " + (suiSignerRef ? `The active wallet is on ${suiNetwork} at ${suiSignerRef.address ?? "?"}. ` : "
|
|
2923
|
+
` + (webSearchOn ? "\u2022 Web search \u2014 web_search: search the web for anything you don't know (news, prices, docs). web_search FINDS pages by query; fetch_url READS a specific URL \u2014 use them together.\n" : "") + "\u2022 Planning \u2014 update_plan (track multi-step work), delegate_task (hand a focused subtask to a sub-agent).\n\u2022 Sui blockchain \u2014 you transact yourself; NEVER tell the user to install a browser wallet. " + (suiSignerRef ? `Sui IS set up. The user's primary (active) wallet is on ${suiNetwork} at ${suiSignerRef.address ?? "?"}. ` : "Sui is NOT set up yet. When the user wants anything Sui-related, FIRST ask if they'd like you to set it up, and ask which network (testnet/mainnet) and which wallet option (generate a new key, import a suiprivkey, or use a Rill agent wallet). Then call sui_setup with their choices. Do not attempt other Sui tools until setup succeeds. ") + "Wallet tools: sui_wallets (list ALL wallets + which is primary/active \u2014 answer 'what's my address' from this), sui_balances (ALL coins across ALL addresses on a network \u2014 answer 'what's my balance' with this), sui_create_wallet, sui_import_wallet, sui_export_wallet (reveal a key only when asked), sui_use_wallet (switch primary). Never overwrite or replace an existing wallet \u2014 adding a wallet keeps the others. If it's unclear which wallet to use, ask the user which is their primary. On-chain: sui_balance & sui_object (read), sui_transfer (send SUI/any coin \u2014 amounts in MIST, 1 SUI = 1e9), sui_move_call (call ANY Move function), sui_execute_ptb (sign a builder/Rill PTB). Prefer sui_transfer for sends and sui_move_call for contract calls; confirm details before signing.\nWhen ANY Sui tool fails, do NOT paste raw JSON or stack traces \u2014 explain what went wrong in one plain sentence and what to do next. Present balances/results readably (SUI amounts in whole SUI, short addresses), and keep every answer brief and to the point.",
|
|
2861
2924
|
tools: [echoTool, suiSetupTool, ...walletTools, fetchUrlTool, ...webTools],
|
|
2862
2925
|
plugins: [
|
|
2863
2926
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinyai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Thiny AI — a beautiful terminal agent: interactive chat, tools, Walrus memory, and Sui execution.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
"tsup": "^8.5.1",
|
|
39
39
|
"typescript": "^5.5.0",
|
|
40
40
|
"@thiny/core": "0.1.0",
|
|
41
|
-
"@thiny/
|
|
42
|
-
"@thiny/plugin-agents": "0.1.0",
|
|
43
|
-
"@thiny/plugin-web-search": "0.1.0",
|
|
41
|
+
"@thiny/memory-memwal": "0.1.0",
|
|
44
42
|
"@thiny/logger-pino": "0.1.0",
|
|
45
43
|
"@thiny/mcp": "0.1.0",
|
|
46
|
-
"@thiny/
|
|
47
|
-
"@thiny/
|
|
44
|
+
"@thiny/walrus": "0.1.0",
|
|
45
|
+
"@thiny/plugin-agents": "0.1.0",
|
|
48
46
|
"@thiny/plugin-sui": "0.1.0",
|
|
47
|
+
"@thiny/plugin-web-search": "0.1.0",
|
|
49
48
|
"@thiny/model-aisdk": "0.1.0",
|
|
50
|
-
"@thiny/skills": "0.1.0"
|
|
49
|
+
"@thiny/skills": "0.1.0",
|
|
50
|
+
"@thiny/signer-sui": "0.1.0"
|
|
51
51
|
},
|
|
52
52
|
"author": "Thiny AI",
|
|
53
53
|
"engines": {
|