@noobdemon/noob-cli 1.7.8 → 1.7.10
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/package.json +1 -1
- package/src/agent.js +16 -20
- package/src/api.js +107 -11
- package/src/repl.js +33 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -277,30 +277,24 @@ function memoryBlock() {
|
|
|
277
277
|
// The proxy is stateless, so we serialize the whole transcript into one prompt.
|
|
278
278
|
// extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
|
|
279
279
|
// chèn ngay sau SYSTEM để model biết và dùng được.
|
|
280
|
-
function
|
|
281
|
-
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
282
|
-
// Thứ tự CÓ CHỦ ĐÍCH: SYSTEM → memoryBlock (Rules dự án, vị trí mạnh thứ 2,
|
|
283
|
-
// tránh lost-in-the-middle) → goalBlock (hard completion requirement, attention
|
|
284
|
-
// cao — chống agentic laziness + goal drift) → extraToolsDoc → runtimeContext
|
|
285
|
-
// → filesLedger → CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm
|
|
286
|
-
// sát SYSTEM để model coi là luật.
|
|
280
|
+
function buildSystem(history, extraToolsDoc, goal) {
|
|
287
281
|
const parts = [SYSTEM, "", memoryBlock()];
|
|
288
|
-
if (goal && goal.trim())
|
|
289
|
-
parts.push("", goalBlock(goal));
|
|
290
|
-
}
|
|
282
|
+
if (goal && goal.trim()) parts.push("", goalBlock(goal));
|
|
291
283
|
if (extraToolsDoc) parts.push("", extraToolsDoc);
|
|
292
|
-
parts.push("", runtimeContext()
|
|
284
|
+
parts.push("", runtimeContext());
|
|
285
|
+
return parts.join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildUserMessage(history) {
|
|
289
|
+
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
290
|
+
const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
|
|
293
291
|
for (const m of msgs) {
|
|
294
292
|
if (m.role === "user") parts.push(`## USER\n${m.content}`);
|
|
295
293
|
else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
|
|
296
294
|
else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
|
|
297
295
|
parts.push("");
|
|
298
296
|
}
|
|
299
|
-
parts.push("=".repeat(60));
|
|
300
|
-
// Recency bias: câu chốt cuối prompt nằm ở vị trí attention mạnh nhất. Nhắc
|
|
301
|
-
// model đối chiếu FILES CHANGED trước khi claim đã sửa file — chống ảo giác
|
|
302
|
-
// "đã tạo file" khi chưa gọi write_file/edit_file.
|
|
303
|
-
parts.push("Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
|
|
297
|
+
parts.push("=".repeat(60), "Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
|
|
304
298
|
return parts.join("\n");
|
|
305
299
|
}
|
|
306
300
|
|
|
@@ -376,8 +370,9 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
376
370
|
// Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
|
|
377
371
|
try { await maybeSummarize(history, { model, signal }); } catch {}
|
|
378
372
|
|
|
379
|
-
const
|
|
380
|
-
|
|
373
|
+
const system = buildSystem(history, extraToolsDoc, goal);
|
|
374
|
+
const message = buildUserMessage(history);
|
|
375
|
+
tokenMeter?.addInput(countTokens(message));
|
|
381
376
|
onStatus?.("thinking");
|
|
382
377
|
onDelta?.({ type: "step-start" });
|
|
383
378
|
// Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
|
|
@@ -385,7 +380,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
385
380
|
// trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
|
|
386
381
|
// throw ApiError retryable (network drop, 5xx, timeout).
|
|
387
382
|
const { text, finishReason } = await streamWithRetry({
|
|
388
|
-
model, message
|
|
383
|
+
model, message, system, signal, tokenMeter, onDelta, onStatus,
|
|
389
384
|
});
|
|
390
385
|
tokenMeter?.endOutput();
|
|
391
386
|
onDelta?.({ type: "step-end" });
|
|
@@ -426,7 +421,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
426
421
|
* backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
|
|
427
422
|
* - Throw lại nếu signal abort hoặc lỗi không retryable.
|
|
428
423
|
*/
|
|
429
|
-
async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, onStatus }) {
|
|
424
|
+
async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus }) {
|
|
430
425
|
const MAX_RETRIES = 8;
|
|
431
426
|
let lastErr = null;
|
|
432
427
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
@@ -435,6 +430,7 @@ async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, on
|
|
|
435
430
|
mode: "chat",
|
|
436
431
|
model,
|
|
437
432
|
message,
|
|
433
|
+
system,
|
|
438
434
|
signal,
|
|
439
435
|
onDelta: (d) => {
|
|
440
436
|
tokenMeter?.pushOutputDelta(d);
|
package/src/api.js
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
// upstream. The CLI only ever sees the gateway URL + the user's key.
|
|
4
4
|
import { config } from "./config.js";
|
|
5
5
|
|
|
6
|
+
// ── memoryToken: per-session random token for upstream conversation state.
|
|
7
|
+
// Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
|
|
8
|
+
// Upstream requires both `remember: true` and a valid `memoryToken`.
|
|
9
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
let _sessionMemoryToken = null;
|
|
11
|
+
|
|
12
|
+
function makeUUID() {
|
|
13
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
14
|
+
const r = (Math.random() * 16) | 0;
|
|
15
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
16
|
+
return v.toString(16);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeMemoryToken() {
|
|
21
|
+
return `${makeUUID()}_${makeUUID()}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getMemoryToken() {
|
|
25
|
+
if (!_sessionMemoryToken) _sessionMemoryToken = makeMemoryToken();
|
|
26
|
+
return _sessionMemoryToken;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resetMemoryToken() {
|
|
30
|
+
_sessionMemoryToken = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
// Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
|
|
7
34
|
// revocation proxy. Off by default. Prefer fixing the trust store.
|
|
8
35
|
// LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
|
|
@@ -89,7 +116,7 @@ function hasUnclosedToolBlock(text) {
|
|
|
89
116
|
*
|
|
90
117
|
* @returns {Promise<{text:string, reasoning:string}>}
|
|
91
118
|
*/
|
|
92
|
-
export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs =
|
|
119
|
+
export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, idleMs = 25000, maxContinues = Infinity }) {
|
|
93
120
|
const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
|
|
94
121
|
|
|
95
122
|
let fullText = "";
|
|
@@ -99,7 +126,7 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
|
|
|
99
126
|
let emptyStreak = 0; // số lần liên tiếp stream rỗng (chống loop vô tận khi upstream chết hẳn)
|
|
100
127
|
|
|
101
128
|
for (let attempt = 0; ; attempt++) {
|
|
102
|
-
const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
|
|
129
|
+
const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning });
|
|
103
130
|
fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
|
|
104
131
|
if (r.reasoning) reasoning = r.reasoning;
|
|
105
132
|
|
|
@@ -148,8 +175,18 @@ function continuationPrompt(message, partial) {
|
|
|
148
175
|
* One network attempt of the stream. Returns this attempt's accumulated text +
|
|
149
176
|
* a `truncated` flag telling the caller whether the reply was cut short.
|
|
150
177
|
*/
|
|
151
|
-
async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
|
|
152
|
-
|
|
178
|
+
async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning }) {
|
|
179
|
+
// chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
|
|
180
|
+
// Worker gateway (handleChat) + upstream đều nhận shape này.
|
|
181
|
+
let body;
|
|
182
|
+
if (mode === "search") body = { query: message };
|
|
183
|
+
else if (mode === "merge") body = { message };
|
|
184
|
+
else {
|
|
185
|
+
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
186
|
+
if (system) body.customInstructions = system;
|
|
187
|
+
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
188
|
+
if (effort) body.effort = effort;
|
|
189
|
+
}
|
|
153
190
|
|
|
154
191
|
// Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
|
|
155
192
|
// huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
|
|
@@ -158,14 +195,65 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
158
195
|
let timedOut = false;
|
|
159
196
|
const onUserAbort = () => ctrl.abort();
|
|
160
197
|
signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
// Hai loại timer tách rời (xem comment dài bên dưới):
|
|
199
|
+
// - WIRE: kết nối TCP còn thở không? Reset mỗi khi reader.read() trả bytes
|
|
200
|
+
// (kể cả comment SSE `: keepalive` từ worker). Ngưỡng cao (idleMs*2) — chỉ
|
|
201
|
+
// để bắt trường hợp socket chết cứng không còn cả heartbeat.
|
|
202
|
+
// - CONTENT: upstream có thực sự đang nghĩ/nói không? Reset chỉ khi parser
|
|
203
|
+
// thấy delta/status/reasoning/done/truncated thật. WARN/PROBE/idleMs gắn
|
|
204
|
+
// vào timer này. Đây là fix cho bug: worker phát `: keepalive\n\n` mỗi 15s
|
|
205
|
+
// bất kể upstream Vercel còn sống hay đã treo → nếu reset idle theo wire,
|
|
206
|
+
// status đếm thời gian sẽ chạy mãi không bao giờ trigger probe/abort.
|
|
207
|
+
let wireIdle = null;
|
|
208
|
+
let contentIdle = null;
|
|
209
|
+
let warnTimer = null;
|
|
210
|
+
let probeTimer = null;
|
|
211
|
+
let probeInFlight = false;
|
|
212
|
+
const WARN_MS = Math.min(8000, idleMs / 3);
|
|
213
|
+
const PROBE_MS = Math.min(12000, (idleMs * 2) / 3);
|
|
214
|
+
const WIRE_MS = idleMs * 2; // socket dead-cứng (mất cả heartbeat) — ngưỡng rộng
|
|
215
|
+
const clearContentTimers = () => {
|
|
216
|
+
clearTimeout(warnTimer); warnTimer = null;
|
|
217
|
+
clearTimeout(probeTimer); probeTimer = null;
|
|
218
|
+
clearTimeout(contentIdle); contentIdle = null;
|
|
219
|
+
};
|
|
220
|
+
const armContent = () => {
|
|
221
|
+
clearContentTimers();
|
|
222
|
+
warnTimer = setTimeout(() => {
|
|
223
|
+
if (onStatus) onStatus("Đang chờ proxy phản hồi…");
|
|
224
|
+
}, WARN_MS);
|
|
225
|
+
probeTimer = setTimeout(async () => {
|
|
226
|
+
if (probeInFlight || ctrl.signal.aborted) return;
|
|
227
|
+
probeInFlight = true;
|
|
228
|
+
try {
|
|
229
|
+
const probeCtl = new AbortController();
|
|
230
|
+
const probeT = setTimeout(() => probeCtl.abort(), 3000);
|
|
231
|
+
const r = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders(), signal: probeCtl.signal });
|
|
232
|
+
clearTimeout(probeT);
|
|
233
|
+
if (!r.ok && r.status >= 500) throw new Error("proxy 5xx");
|
|
234
|
+
} catch {
|
|
235
|
+
if (!ctrl.signal.aborted) {
|
|
236
|
+
timedOut = true;
|
|
237
|
+
if (onStatus) onStatus("Proxy không phản hồi — đang gọi lại model…");
|
|
238
|
+
ctrl.abort();
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
probeInFlight = false;
|
|
242
|
+
}
|
|
243
|
+
}, PROBE_MS);
|
|
244
|
+
contentIdle = setTimeout(() => {
|
|
165
245
|
timedOut = true;
|
|
166
246
|
ctrl.abort();
|
|
167
247
|
}, idleMs);
|
|
168
248
|
};
|
|
249
|
+
const armWire = () => {
|
|
250
|
+
clearTimeout(wireIdle);
|
|
251
|
+
wireIdle = setTimeout(() => {
|
|
252
|
+
// Mất cả heartbeat → socket chết cứng. Abort, lớp trên sẽ retry.
|
|
253
|
+
timedOut = true;
|
|
254
|
+
ctrl.abort();
|
|
255
|
+
}, WIRE_MS);
|
|
256
|
+
};
|
|
169
257
|
|
|
170
258
|
let text = "";
|
|
171
259
|
let reasoning = "";
|
|
@@ -184,6 +272,12 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
184
272
|
} catch {
|
|
185
273
|
return;
|
|
186
274
|
}
|
|
275
|
+
// Có ít nhất 1 field nội dung thực → upstream đang nghĩ/nói. Reset content
|
|
276
|
+
// idle/warn/probe. JSON rỗng `{}` (ping) hoặc comment `: keepalive` của
|
|
277
|
+
// worker KHÔNG khớp điều kiện này → không che mất idle detection.
|
|
278
|
+
if (p.status || p.delta || p.reasoning || p.done || p.truncated || p.error) {
|
|
279
|
+
armContent();
|
|
280
|
+
}
|
|
187
281
|
if (p.status && onStatus) onStatus(p.status);
|
|
188
282
|
if (p.delta) {
|
|
189
283
|
text += p.delta;
|
|
@@ -200,7 +294,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
200
294
|
};
|
|
201
295
|
|
|
202
296
|
try {
|
|
203
|
-
|
|
297
|
+
armWire();
|
|
298
|
+
armContent();
|
|
204
299
|
const resp = await fetch(config.gatewayUrl + endpoint, {
|
|
205
300
|
method: "POST",
|
|
206
301
|
headers: authHeaders(),
|
|
@@ -214,7 +309,7 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
214
309
|
let buf = "";
|
|
215
310
|
while (true) {
|
|
216
311
|
const { done, value } = await reader.read();
|
|
217
|
-
|
|
312
|
+
armWire(); // bất kỳ byte nào tới → socket còn thở, gia hạn wire idle
|
|
218
313
|
if (done) break;
|
|
219
314
|
buf += decoder.decode(value, { stream: true });
|
|
220
315
|
let nl;
|
|
@@ -239,7 +334,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
239
334
|
if (mode === "chat" && text) return { text, reasoning, truncated: true };
|
|
240
335
|
throw err;
|
|
241
336
|
} finally {
|
|
242
|
-
clearTimeout(
|
|
337
|
+
clearTimeout(wireIdle);
|
|
338
|
+
clearContentTimers();
|
|
243
339
|
signal?.removeEventListener("abort", onUserAbort);
|
|
244
340
|
}
|
|
245
341
|
}
|
package/src/repl.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createTui } from "./tui.js";
|
|
|
6
6
|
import { runAgent, maybeSummarize } from "./agent.js";
|
|
7
7
|
import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
|
|
8
8
|
import { TokenMeter } from "./tokens.js";
|
|
9
|
-
import { stream, usage, ApiError } from "./api.js";
|
|
9
|
+
import { stream, usage, ApiError, resetMemoryToken } from "./api.js";
|
|
10
10
|
import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
|
|
11
11
|
import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
|
|
12
12
|
import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
|
|
@@ -360,7 +360,11 @@ export async function startRepl(opts = {}) {
|
|
|
360
360
|
);
|
|
361
361
|
console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
|
|
362
362
|
}
|
|
363
|
-
const startFresh = () =>
|
|
363
|
+
const startFresh = () => {
|
|
364
|
+
session = sessions.newSession({ cwd: process.cwd(), model: state.model.id });
|
|
365
|
+
// Reset per-session upstream memory token so the next chat starts fresh.
|
|
366
|
+
resetMemoryToken();
|
|
367
|
+
};
|
|
364
368
|
|
|
365
369
|
// /frontend-design <yêu cầu> — vận dụng skill frontend-design (skills/frontend-design/SKILL.md)
|
|
366
370
|
// để model tạo UI frontend chất lượng cao, tránh "AI slop" aesthetic.
|
|
@@ -535,6 +539,20 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
535
539
|
// bắt nhầm khi model chỉ NHẮC tới token giữa văn xuôi. Không bao giờ chấp nhận
|
|
536
540
|
// ở lượt lập kế hoạch (xem vòng lặp bên dưới).
|
|
537
541
|
const ultraIsDone = (a) => a.trimEnd().endsWith(ULTRA_DONE);
|
|
542
|
+
// Detect "stuck": model bối rối, không nhận task, chỉ hỏi lại user hoặc spam
|
|
543
|
+
// list_dir/ls vô nghĩa. Xảy ra khi goal trống nghĩa / bị paste system prompt /
|
|
544
|
+
// model mất ngữ cảnh. 2 vòng stuck liên tiếp → auto-exit để không loop vô hạn.
|
|
545
|
+
const STUCK_PHRASES = [
|
|
546
|
+
"chưa giao task", "chưa nêu tác vụ", "chưa có yêu cầu", "chưa có task",
|
|
547
|
+
"không nhận task", "không thể nhận vai", "bạn muốn mình làm gì",
|
|
548
|
+
"chưa rõ yêu cầu", "cần mục tiêu rõ", "vui lòng cho biết",
|
|
549
|
+
"please provide", "what would you like", "no task", "clarify",
|
|
550
|
+
];
|
|
551
|
+
const ultraLooksStuck = (a) => {
|
|
552
|
+
if (!a) return true;
|
|
553
|
+
const s = a.toLowerCase();
|
|
554
|
+
return STUCK_PHRASES.some((p) => s.includes(p));
|
|
555
|
+
};
|
|
538
556
|
const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
|
|
539
557
|
Mục tiêu tổng: ${goal}
|
|
540
558
|
|
|
@@ -568,10 +586,23 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
|
|
|
568
586
|
let answer = await handle(ultraStart(goal));
|
|
569
587
|
persist();
|
|
570
588
|
let i = 0;
|
|
589
|
+
let stuckStreak = 0; // đếm vòng liên tiếp model bối rối / không nhận task
|
|
590
|
+
const STUCK_MAX = 2;
|
|
571
591
|
// Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
|
|
572
592
|
// "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
|
|
593
|
+
// Cũng dừng sớm nếu model 2 vòng liên tiếp tỏ ra mất ngữ cảnh (goal trống
|
|
594
|
+
// nghĩa, paste system prompt, model hỏi lại user) → tránh spam list_dir.
|
|
573
595
|
while (state.ultra && i < MAX_QUESTS) {
|
|
574
596
|
if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
|
|
597
|
+
if (ultraLooksStuck(answer)) {
|
|
598
|
+
stuckStreak++;
|
|
599
|
+
if (stuckStreak >= STUCK_MAX) {
|
|
600
|
+
console.log(c.err(" ⚠ ULTRA stuck: model không nhận task " + stuckStreak + " vòng liên tiếp. Thoát. Gõ /ultra <mục tiêu rõ> để thử lại."));
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
stuckStreak = 0;
|
|
605
|
+
}
|
|
575
606
|
i++;
|
|
576
607
|
console.log(c.accent(" ↻ " + t.ultraQuest(i)));
|
|
577
608
|
answer = await handle(ultraContinue(goal));
|