@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.0
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/CHANGELOG.md +98 -1
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/cli/gallery-cli.d.ts +43 -0
- package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
- package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
- package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
- package/dist/types/cli/gallery-screenshot.d.ts +35 -0
- package/dist/types/commands/gallery.d.ts +47 -0
- package/dist/types/config/keybindings.d.ts +10 -2
- package/dist/types/config/model-id-affixes.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +8 -1
- package/dist/types/config/settings-schema.d.ts +43 -7
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
- package/dist/types/lsp/types.d.ts +10 -0
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memory-backend/index.d.ts +2 -1
- package/dist/types/memory-backend/resolve.d.ts +1 -1
- package/dist/types/memory-backend/types.d.ts +1 -1
- package/dist/types/modes/components/assistant-message.d.ts +5 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +18 -0
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/index.d.ts +5 -4
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/setup-version.d.ts +11 -0
- package/dist/types/modes/setup-wizard/index.d.ts +2 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/telemetry-export.d.ts +1 -1
- package/dist/types/tools/eval-render.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +15 -7
- package/dist/types/tools/render-utils.d.ts +33 -0
- package/dist/types/tools/renderers.d.ts +16 -2
- package/dist/types/tools/search.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/dist/types/web/scrapers/github.d.ts +22 -0
- package/dist/types/web/search/providers/perplexity.d.ts +8 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +9 -9
- package/scripts/dev-launch +42 -0
- package/scripts/dev-launch-preload.ts +19 -0
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/args.ts +2 -2
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/cli/gallery-cli.ts +223 -0
- package/src/cli/gallery-fixtures/agentic.ts +292 -0
- package/src/cli/gallery-fixtures/codeintel.ts +188 -0
- package/src/cli/gallery-fixtures/edit.ts +194 -0
- package/src/cli/gallery-fixtures/fs.ts +153 -0
- package/src/cli/gallery-fixtures/index.ts +40 -0
- package/src/cli/gallery-fixtures/interaction.ts +49 -0
- package/src/cli/gallery-fixtures/memory.ts +81 -0
- package/src/cli/gallery-fixtures/misc.ts +221 -0
- package/src/cli/gallery-fixtures/search.ts +213 -0
- package/src/cli/gallery-fixtures/shell.ts +167 -0
- package/src/cli/gallery-fixtures/types.ts +41 -0
- package/src/cli/gallery-fixtures/web.ts +158 -0
- package/src/cli/gallery-screenshot.ts +279 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/gallery.ts +52 -0
- package/src/commands/launch.ts +1 -1
- package/src/config/keybindings.ts +68 -2
- package/src/config/model-equivalence.ts +35 -12
- package/src/config/model-id-affixes.ts +39 -22
- package/src/config/model-registry.ts +16 -16
- package/src/config/settings-schema.ts +29 -6
- package/src/config/settings.ts +11 -0
- package/src/dap/client.ts +14 -16
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +43 -55
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +102 -58
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/agent-bridge.ts +38 -12
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/plugins/doctor.ts +0 -1
- package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
- package/src/goals/tools/goal-tool.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/lsp/client.ts +179 -52
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/lsp/types.ts +10 -0
- package/src/main.ts +47 -52
- package/src/memory-backend/index.ts +13 -1
- package/src/memory-backend/resolve.ts +3 -5
- package/src/memory-backend/types.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +22 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/custom-editor.ts +10 -1
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/status-line.ts +3 -5
- package/src/modes/components/tool-execution.ts +83 -24
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/controllers/command-controller.ts +13 -118
- package/src/modes/controllers/event-controller.ts +26 -10
- package/src/modes/controllers/input-controller.ts +11 -3
- package/src/modes/controllers/selector-controller.ts +40 -3
- package/src/modes/index.ts +5 -4
- package/src/modes/interactive-mode.ts +21 -7
- package/src/modes/setup-version.ts +11 -0
- package/src/modes/setup-wizard/index.ts +3 -2
- package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +2 -2
- package/src/modes/utils/context-usage.ts +10 -6
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +21 -23
- package/src/session/agent-session.ts +13 -9
- package/src/slash-commands/builtin-registry.ts +4 -12
- package/src/slash-commands/helpers/usage-report.ts +2 -0
- package/src/task/executor.ts +20 -2
- package/src/task/render.ts +37 -11
- package/src/telemetry-export.ts +25 -7
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-backends.ts +6 -17
- package/src/tools/eval-render.ts +28 -10
- package/src/tools/eval.ts +19 -23
- package/src/tools/fetch.ts +99 -89
- package/src/tools/read.ts +7 -7
- package/src/tools/render-utils.ts +63 -3
- package/src/tools/renderers.ts +16 -1
- package/src/tools/report-tool-issue.ts +1 -1
- package/src/tools/search.ts +173 -81
- package/src/tools/ssh.ts +21 -8
- package/src/tools/todo.ts +20 -7
- package/src/tools/write.ts +39 -9
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/output-block.ts +14 -0
- package/src/web/scrapers/github.ts +255 -3
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/providers/perplexity.ts +199 -51
- package/src/web/search/render.ts +42 -57
- package/src/web/search/types.ts +5 -1
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/__tests__/shared-executors.test.ts +0 -609
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
- /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
package/src/tools/eval-render.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { formatContextUsage } from "../modes/components/status-line/context-thre
|
|
|
18
18
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
19
19
|
import { shimmerEnabled } from "../modes/theme/shimmer";
|
|
20
20
|
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
|
21
|
-
import { borderShimmerTick, renderCodeCell } from "../tui";
|
|
21
|
+
import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
|
|
22
22
|
import {
|
|
23
23
|
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
|
24
24
|
JSON_TREE_MAX_DEPTH_EXPANDED,
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
truncateToWidth,
|
|
40
40
|
wrapBrackets,
|
|
41
41
|
} from "./render-utils";
|
|
42
|
-
|
|
43
42
|
export const EVAL_DEFAULT_PREVIEW_LINES = 10;
|
|
44
43
|
|
|
45
44
|
function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
|
|
@@ -490,10 +489,10 @@ export const evalToolRenderer = {
|
|
|
490
489
|
|
|
491
490
|
let cached: { key: string; width: number; result: string[] } | undefined;
|
|
492
491
|
|
|
493
|
-
return {
|
|
492
|
+
return markFramedBlockComponent({
|
|
494
493
|
render: (width: number): string[] => {
|
|
495
494
|
const animate = options.isPartial && shimmerEnabled();
|
|
496
|
-
const key = `${animate ? borderShimmerTick() : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
|
|
495
|
+
const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
|
|
497
496
|
if (cached && cached.key === key && cached.width === width) {
|
|
498
497
|
return cached.result;
|
|
499
498
|
}
|
|
@@ -510,8 +509,13 @@ export const evalToolRenderer = {
|
|
|
510
509
|
title: cell.title,
|
|
511
510
|
status: "pending",
|
|
512
511
|
width,
|
|
513
|
-
|
|
514
|
-
|
|
512
|
+
// Always render the full source: the code is fixed input, not the
|
|
513
|
+
// streaming part, so it is never compacted. While still pending
|
|
514
|
+
// (args streaming) the block is not yet committed to native
|
|
515
|
+
// scrollback — its head is only committed once a result exists and
|
|
516
|
+
// the code has finalized (see `isStreamingPreviewAppendOnly`).
|
|
517
|
+
codeMaxLines: Number.POSITIVE_INFINITY,
|
|
518
|
+
expanded: options.expanded,
|
|
515
519
|
animate,
|
|
516
520
|
},
|
|
517
521
|
uiTheme,
|
|
@@ -527,7 +531,7 @@ export const evalToolRenderer = {
|
|
|
527
531
|
invalidate: () => {
|
|
528
532
|
cached = undefined;
|
|
529
533
|
},
|
|
530
|
-
};
|
|
534
|
+
});
|
|
531
535
|
},
|
|
532
536
|
|
|
533
537
|
renderResult(
|
|
@@ -571,7 +575,7 @@ export const evalToolRenderer = {
|
|
|
571
575
|
if (cellResults && cellResults.length > 0) {
|
|
572
576
|
let cached: { key: string; width: number; result: string[] } | undefined;
|
|
573
577
|
|
|
574
|
-
return {
|
|
578
|
+
return markFramedBlockComponent({
|
|
575
579
|
render: (width: number): string[] => {
|
|
576
580
|
const expanded = options.renderContext?.expanded ?? options.expanded;
|
|
577
581
|
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
|
|
@@ -613,7 +617,9 @@ export const evalToolRenderer = {
|
|
|
613
617
|
duration: cell.durationMs,
|
|
614
618
|
output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
|
|
615
619
|
outputMaxLines: outputLines.length,
|
|
616
|
-
|
|
620
|
+
// Code is fixed input — always shown in full, never compacted.
|
|
621
|
+
// Only `output` honors the collapsed preview cap above.
|
|
622
|
+
codeMaxLines: Number.POSITIVE_INFINITY,
|
|
617
623
|
expanded,
|
|
618
624
|
width,
|
|
619
625
|
animate,
|
|
@@ -649,7 +655,7 @@ export const evalToolRenderer = {
|
|
|
649
655
|
invalidate: () => {
|
|
650
656
|
cached = undefined;
|
|
651
657
|
},
|
|
652
|
-
};
|
|
658
|
+
});
|
|
653
659
|
}
|
|
654
660
|
|
|
655
661
|
const displayOutput = output;
|
|
@@ -745,6 +751,18 @@ export const evalToolRenderer = {
|
|
|
745
751
|
},
|
|
746
752
|
};
|
|
747
753
|
},
|
|
754
|
+
|
|
755
|
+
// Append-only once a result exists (args complete → code finalized). The code
|
|
756
|
+
// is rendered in full as a fixed top-anchored prefix, and the streamed stdout
|
|
757
|
+
// below it only appends rows at the bottom, so the scrolled-off head commits
|
|
758
|
+
// to native scrollback instead of being yanked — collapsed or expanded, since
|
|
759
|
+
// the collapsed output cap keeps its sliding tail in the bottom live region.
|
|
760
|
+
// Returns false while still pending: the code is mid-stream (args incomplete)
|
|
761
|
+
// and its header still reads "pending", so committing it would strand a stale
|
|
762
|
+
// pending preview in history.
|
|
763
|
+
isStreamingPreviewAppendOnly(_args: EvalRenderArgs, _options: RenderResultOptions, result?: unknown): boolean {
|
|
764
|
+
return result != null;
|
|
765
|
+
},
|
|
748
766
|
mergeCallAndResult: true,
|
|
749
767
|
inline: true,
|
|
750
768
|
};
|
package/src/tools/eval.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { jsBackend, pythonBackend } from "../eval";
|
|
6
6
|
import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend";
|
|
7
|
-
import {
|
|
7
|
+
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout";
|
|
8
8
|
import { IdleTimeout } from "../eval/idle-timeout";
|
|
9
9
|
import { defaultEvalSessionId } from "../eval/session-id";
|
|
10
10
|
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
|
|
@@ -130,11 +130,12 @@ function timeoutSecondsFromMs(timeoutMs: number): number {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
async function resolveBackend(session: ToolSession, language: EvalLanguage): Promise<ResolvedBackend> {
|
|
133
|
-
const
|
|
134
|
-
const
|
|
133
|
+
const backends = resolveEvalBackends(session);
|
|
134
|
+
const allowPy = backends.python;
|
|
135
|
+
const allowJs = backends.js;
|
|
135
136
|
|
|
136
137
|
if (language === "python") {
|
|
137
|
-
if (!allowPy) throw new ToolError("Python backend is disabled (eval.py = false).");
|
|
138
|
+
if (!allowPy) throw new ToolError("Python backend is disabled (PI_PY=0 or eval.py = false).");
|
|
138
139
|
if (!(await pythonBackend.isAvailable(session))) {
|
|
139
140
|
throw new ToolError(
|
|
140
141
|
'Python backend is unavailable in this session. Pass language: "js" or install the python kernel.',
|
|
@@ -142,7 +143,7 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro
|
|
|
142
143
|
}
|
|
143
144
|
return { backend: pythonBackend };
|
|
144
145
|
}
|
|
145
|
-
if (!allowJs) throw new ToolError("JavaScript backend is disabled (eval.js = false).");
|
|
146
|
+
if (!allowJs) throw new ToolError("JavaScript backend is disabled (PI_JS=0 or eval.js = false).");
|
|
146
147
|
return { backend: jsBackend };
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -313,16 +314,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
313
314
|
for (let i = 0; i < cells.length; i++) {
|
|
314
315
|
const cell = cells[i];
|
|
315
316
|
const backend = cell.resolved.backend;
|
|
316
|
-
// The per-cell `timeout` is a
|
|
317
|
-
// work
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// agent/llm is bounded by a plain wall-clock timeout. The watchdog
|
|
324
|
-
// drives `combinedSignal`; we pass no wall-clock deadline downstream
|
|
325
|
-
// so the backends never arm a competing fixed timer.
|
|
317
|
+
// The per-cell `timeout` is a budget on the cell runtime's *own*
|
|
318
|
+
// work. Host-side `agent()`/`parallel()`/`llm()` bridge calls suspend
|
|
319
|
+
// that budget entirely and restart a fresh timeout window when control
|
|
320
|
+
// returns to Python/JS. Compute, stdout, `log()`/`phase()`, and
|
|
321
|
+
// ordinary tool calls all count against the budget. The watchdog drives
|
|
322
|
+
// `combinedSignal`; we pass no wall-clock deadline downstream so the
|
|
323
|
+
// backends never arm a competing fixed timer.
|
|
326
324
|
const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
|
|
327
325
|
const idle = new IdleTimeout(idleTimeoutMs);
|
|
328
326
|
const combinedSignal = signal
|
|
@@ -355,14 +353,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
355
353
|
outputSink!.push(chunk);
|
|
356
354
|
},
|
|
357
355
|
onStatus: event => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (event.op === EVAL_HEARTBEAT_OP) {
|
|
365
|
-
idle.bump();
|
|
356
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
|
|
357
|
+
idle.pause();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) {
|
|
361
|
+
idle.resume();
|
|
366
362
|
return;
|
|
367
363
|
}
|
|
368
364
|
cellResult.statusEvents ??= [];
|
package/src/tools/fetch.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ToolSession } from "../sdk";
|
|
|
14
14
|
import type { AgentStorage } from "../session/agent-storage";
|
|
15
15
|
import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
|
|
16
16
|
import { renderStatusLine, urlHyperlink } from "../tui";
|
|
17
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
17
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
18
18
|
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
|
19
19
|
import { ensureTool } from "../utils/tools-manager";
|
|
20
20
|
import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
|
|
@@ -581,14 +581,25 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
|
|
|
581
581
|
*/
|
|
582
582
|
const REMOTE_READER_MAX_MS = 10_000;
|
|
583
583
|
|
|
584
|
+
/** Reader backends for {@link renderHtmlToText}, in default priority order. */
|
|
585
|
+
export type FetchProvider = "native" | "trafilatura" | "lynx" | "parallel" | "jina";
|
|
586
|
+
|
|
587
|
+
const FETCH_PROVIDER_ORDER: readonly FetchProvider[] = ["native", "trafilatura", "lynx", "parallel", "jina"];
|
|
588
|
+
|
|
584
589
|
/**
|
|
585
|
-
* Render HTML to markdown
|
|
586
|
-
* in-process
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
590
|
+
* Render HTML to markdown by trying reader backends in priority order: native
|
|
591
|
+
* (in-process), trafilatura, lynx, Parallel, then Jina. The `providers.fetch`
|
|
592
|
+
* setting picks the order — `auto` uses the default above; any specific backend
|
|
593
|
+
* is tried first, then the remaining backends as fallbacks. Every backend's
|
|
594
|
+
* output must clear the same quality gate (>100 non-whitespace chars and not
|
|
595
|
+
* {@link isLowQualityOutput}) before it is accepted, otherwise the next backend
|
|
596
|
+
* is tried.
|
|
597
|
+
*
|
|
598
|
+
* The overall `timeout` budget bounds the whole call; remote backends (Parallel,
|
|
599
|
+
* Jina) are additionally capped at `REMOTE_READER_MAX_MS` so a hung endpoint
|
|
600
|
+
* cannot starve later renderers — especially the purely-local native converter,
|
|
601
|
+
* which always works on already-loaded HTML. Only a real `userSignal`
|
|
602
|
+
* cancellation aborts the chain (#1449).
|
|
592
603
|
*/
|
|
593
604
|
export async function renderHtmlToText(
|
|
594
605
|
url: string,
|
|
@@ -607,92 +618,74 @@ export async function renderHtmlToText(
|
|
|
607
618
|
signal: overallSignal,
|
|
608
619
|
};
|
|
609
620
|
const remoteBudgetMs = Math.min(timeout * 1000, REMOTE_READER_MAX_MS);
|
|
610
|
-
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
621
|
+
// Per-attempt budget for remote endpoints so one stall cannot consume the
|
|
622
|
+
// whole reader-mode budget and starve the local fallbacks.
|
|
623
|
+
const remoteSignal = () => ptree.combineSignals(userSignal, remoteBudgetMs);
|
|
624
|
+
|
|
625
|
+
const runners: Record<FetchProvider, () => Promise<string | null>> = {
|
|
626
|
+
// Purely local, no network/subprocess: still works on already-loaded HTML
|
|
627
|
+
// even after remote/subprocess attempts are aborted by the budget.
|
|
628
|
+
native: () => htmlToMarkdown(html, { cleanContent: true }),
|
|
629
|
+
trafilatura: async () => {
|
|
630
|
+
const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
|
|
631
|
+
if (!trafilatura) return null;
|
|
632
|
+
const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
|
|
633
|
+
return result.ok ? result.stdout : null;
|
|
634
|
+
},
|
|
635
|
+
lynx: async () => {
|
|
636
|
+
if (!hasCommand("lynx")) return null;
|
|
637
|
+
const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
|
|
638
|
+
return result.ok ? result.stdout : null;
|
|
639
|
+
},
|
|
640
|
+
parallel: async () => {
|
|
641
|
+
if (!findParallelApiKey(storage)) return null;
|
|
614
642
|
const parallelResult = await extractWithParallel(
|
|
615
643
|
[url],
|
|
616
|
-
{
|
|
617
|
-
objective: "Extract the main content",
|
|
618
|
-
excerpts: true,
|
|
619
|
-
fullContent: false,
|
|
620
|
-
signal: ptree.combineSignals(userSignal, remoteBudgetMs),
|
|
621
|
-
},
|
|
644
|
+
{ objective: "Extract the main content", excerpts: true, fullContent: false, signal: remoteSignal() },
|
|
622
645
|
storage,
|
|
623
646
|
);
|
|
624
647
|
const firstDocument = parallelResult.results[0];
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Try jina reader API with its own sub-budget so a stall cannot starve
|
|
638
|
-
// later fallbacks (#1449).
|
|
639
|
-
try {
|
|
640
|
-
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
641
|
-
const response = await fetch(jinaUrl, {
|
|
642
|
-
headers: { Accept: "text/markdown" },
|
|
643
|
-
signal: ptree.combineSignals(userSignal, remoteBudgetMs),
|
|
644
|
-
});
|
|
645
|
-
if (response.ok) {
|
|
646
|
-
const content = await response.text();
|
|
647
|
-
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
648
|
-
return { content, ok: true, method: "jina" };
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
} catch {
|
|
652
|
-
// Jina failed or stalled; honour real cancellation only.
|
|
653
|
-
userSignal?.throwIfAborted();
|
|
654
|
-
}
|
|
648
|
+
return firstDocument ? getParallelExtractContent(firstDocument) : null;
|
|
649
|
+
},
|
|
650
|
+
jina: async () => {
|
|
651
|
+
const response = await fetch(`https://r.jina.ai/${url}`, {
|
|
652
|
+
headers: { Accept: "text/markdown" },
|
|
653
|
+
signal: remoteSignal(),
|
|
654
|
+
});
|
|
655
|
+
return response.ok ? await response.text() : null;
|
|
656
|
+
},
|
|
657
|
+
};
|
|
655
658
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
659
|
+
const preference = settings.get("providers.fetch");
|
|
660
|
+
const order: readonly FetchProvider[] =
|
|
661
|
+
preference === "auto"
|
|
662
|
+
? FETCH_PROVIDER_ORDER
|
|
663
|
+
: [preference, ...FETCH_PROVIDER_ORDER.filter(method => method !== preference)];
|
|
664
|
+
|
|
665
|
+
// Highest-priority output that is substantial but fails the low-quality gate.
|
|
666
|
+
// Surfaced (ok: true) only when no backend clears the gate, so the caller's
|
|
667
|
+
// targeted fallbacks (llms.txt / document extraction) still run and we beat
|
|
668
|
+
// returning the unrendered raw HTML.
|
|
669
|
+
let lowQuality: { content: string; method: FetchProvider } | null = null;
|
|
670
|
+
|
|
671
|
+
for (const method of order) {
|
|
672
|
+
// Honour real user cancellation between attempts; remote per-attempt and
|
|
673
|
+
// overall-budget timeouts still fall through to later (local) renderers.
|
|
667
674
|
userSignal?.throwIfAborted();
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (lynx) {
|
|
674
|
-
const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
|
|
675
|
-
if (result.ok) {
|
|
676
|
-
return { content: result.stdout, ok: true, method: "lynx" };
|
|
675
|
+
try {
|
|
676
|
+
const content = await runners[method]();
|
|
677
|
+
if (!content || content.trim().length <= 100) continue;
|
|
678
|
+
if (!isLowQualityOutput(content)) {
|
|
679
|
+
return { content, ok: true, method };
|
|
677
680
|
}
|
|
681
|
+
lowQuality ??= { content, method };
|
|
682
|
+
} catch {
|
|
683
|
+
userSignal?.throwIfAborted();
|
|
678
684
|
}
|
|
679
|
-
} catch {
|
|
680
|
-
// lynx failed or stalled; continue to native converter.
|
|
681
|
-
userSignal?.throwIfAborted();
|
|
682
685
|
}
|
|
683
686
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
// by the overall reader-mode timeout, this still works on already-loaded
|
|
687
|
-
// HTML (#1449).
|
|
688
|
-
try {
|
|
689
|
-
const content = await htmlToMarkdown(html, { cleanContent: true });
|
|
690
|
-
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
691
|
-
return { content, ok: true, method: "native" };
|
|
692
|
-
}
|
|
693
|
-
} catch {
|
|
694
|
-
// Native converter failed; nothing else to try.
|
|
695
|
-
userSignal?.throwIfAborted();
|
|
687
|
+
if (lowQuality) {
|
|
688
|
+
return { content: lowQuality.content, ok: true, method: lowQuality.method };
|
|
696
689
|
}
|
|
697
690
|
return { content: "", ok: false, method: "none" };
|
|
698
691
|
}
|
|
@@ -1141,10 +1134,27 @@ async function renderUrl(
|
|
|
1141
1134
|
throw new ToolAbortError();
|
|
1142
1135
|
}
|
|
1143
1136
|
|
|
1144
|
-
// 5E: Render HTML
|
|
1137
|
+
// 5E: Render HTML via the reader-backend chain (native/trafilatura/lynx/parallel/jina)
|
|
1145
1138
|
const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
|
|
1146
1139
|
if (!htmlResult.ok) {
|
|
1147
|
-
notes.push("html rendering failed (
|
|
1140
|
+
notes.push("html rendering failed (no reader backend produced usable output)");
|
|
1141
|
+
|
|
1142
|
+
const llmResult = await tryLlmEndpoints(finalUrl, timeout, signal);
|
|
1143
|
+
if (llmResult) {
|
|
1144
|
+
notes.push(`Used llms.txt fallback: ${llmResult.endpoint}`);
|
|
1145
|
+
const output = finalizeOutput(llmResult.content);
|
|
1146
|
+
return {
|
|
1147
|
+
url,
|
|
1148
|
+
finalUrl,
|
|
1149
|
+
contentType: "text/plain",
|
|
1150
|
+
method: "llms.txt",
|
|
1151
|
+
content: output.content,
|
|
1152
|
+
fetchedAt,
|
|
1153
|
+
truncated: output.truncated,
|
|
1154
|
+
notes,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1148
1158
|
const output = finalizeOutput(rawContent);
|
|
1149
1159
|
return {
|
|
1150
1160
|
url,
|
|
@@ -1488,11 +1498,11 @@ export function renderReadUrlResult(
|
|
|
1488
1498
|
const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
|
|
1489
1499
|
const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
|
|
1490
1500
|
const outputBlock = new CachedOutputBlock();
|
|
1491
|
-
return {
|
|
1501
|
+
return markFramedBlockComponent({
|
|
1492
1502
|
render: (width: number) =>
|
|
1493
1503
|
outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
|
|
1494
1504
|
invalidate: () => outputBlock.invalidate(),
|
|
1495
|
-
};
|
|
1505
|
+
});
|
|
1496
1506
|
}
|
|
1497
1507
|
|
|
1498
1508
|
const description = formatReadUrlDescription(details.finalUrl);
|
|
@@ -1542,7 +1552,7 @@ export function renderReadUrlResult(
|
|
|
1542
1552
|
let lastExpanded: boolean | undefined;
|
|
1543
1553
|
let contentPreviewLines: string[] | undefined;
|
|
1544
1554
|
|
|
1545
|
-
return {
|
|
1555
|
+
return markFramedBlockComponent({
|
|
1546
1556
|
render: (width: number) => {
|
|
1547
1557
|
const { expanded } = options;
|
|
1548
1558
|
|
|
@@ -1582,5 +1592,5 @@ export function renderReadUrlResult(
|
|
|
1582
1592
|
contentPreviewLines = undefined;
|
|
1583
1593
|
lastExpanded = undefined;
|
|
1584
1594
|
},
|
|
1585
|
-
};
|
|
1595
|
+
});
|
|
1586
1596
|
}
|
package/src/tools/read.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
truncateLine,
|
|
30
30
|
} from "../session/streaming-output";
|
|
31
31
|
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
|
|
32
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
32
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
33
33
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
34
34
|
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
|
|
35
35
|
import { convertFileWithMarkit } from "../utils/markit";
|
|
@@ -2413,11 +2413,11 @@ export const readToolRenderer = {
|
|
|
2413
2413
|
const header = renderStatusLine({ icon: "error", title }, uiTheme);
|
|
2414
2414
|
const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
|
|
2415
2415
|
const outputBlock = new CachedOutputBlock();
|
|
2416
|
-
return {
|
|
2416
|
+
return markFramedBlockComponent({
|
|
2417
2417
|
render: (width: number) =>
|
|
2418
2418
|
outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
|
|
2419
2419
|
invalidate: () => outputBlock.invalidate(),
|
|
2420
|
-
};
|
|
2420
|
+
});
|
|
2421
2421
|
}
|
|
2422
2422
|
const details = result.details;
|
|
2423
2423
|
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
@@ -2465,7 +2465,7 @@ export const readToolRenderer = {
|
|
|
2465
2465
|
const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
|
|
2466
2466
|
const lines = [...detailLines, ...warningLines];
|
|
2467
2467
|
const outputBlock = new CachedOutputBlock();
|
|
2468
|
-
return {
|
|
2468
|
+
return markFramedBlockComponent({
|
|
2469
2469
|
render: (width: number) =>
|
|
2470
2470
|
outputBlock.render(
|
|
2471
2471
|
{
|
|
@@ -2482,7 +2482,7 @@ export const readToolRenderer = {
|
|
|
2482
2482
|
uiTheme,
|
|
2483
2483
|
),
|
|
2484
2484
|
invalidate: () => outputBlock.invalidate(),
|
|
2485
|
-
};
|
|
2485
|
+
});
|
|
2486
2486
|
}
|
|
2487
2487
|
|
|
2488
2488
|
const suffix = details?.suffixResolution;
|
|
@@ -2514,7 +2514,7 @@ export const readToolRenderer = {
|
|
|
2514
2514
|
let cachedWidth: number | undefined;
|
|
2515
2515
|
let cachedExpanded: boolean | undefined;
|
|
2516
2516
|
let cachedLines: string[] | undefined;
|
|
2517
|
-
return {
|
|
2517
|
+
return markFramedBlockComponent({
|
|
2518
2518
|
render: (width: number) => {
|
|
2519
2519
|
const expanded = options.expanded;
|
|
2520
2520
|
if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
|
|
@@ -2551,7 +2551,7 @@ export const readToolRenderer = {
|
|
|
2551
2551
|
cachedExpanded = undefined;
|
|
2552
2552
|
cachedLines = undefined;
|
|
2553
2553
|
},
|
|
2554
|
-
};
|
|
2554
|
+
});
|
|
2555
2555
|
},
|
|
2556
2556
|
mergeCallAndResult: true,
|
|
2557
2557
|
};
|
|
@@ -171,14 +171,74 @@ export function formatMoreItems(remaining: number, itemType: string): string {
|
|
|
171
171
|
return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Maximum rows a tool's streaming/pending *call* preview may render before it is
|
|
176
|
+
* capped. This is intentionally conservative: the preview still sits inside a
|
|
177
|
+
* transcript that already consumed some viewport rows, and tool blocks carry
|
|
178
|
+
* extra chrome (status/header/border/"more lines"), so a "reasonable" raw code
|
|
179
|
+
* or command preview like 10-12 lines can still overflow and strand its top
|
|
180
|
+
* while the block is volatile. Keeping the live call window short avoids that
|
|
181
|
+
* across terminals without turning the transcript into an interactive scroller.
|
|
182
|
+
*/
|
|
183
|
+
export const CALL_PREVIEW_MAX_LINES = 6;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Cap a pre-rendered pending/call preview to a bounded window. When truncated,
|
|
187
|
+
* show both the head and the live tail so the user can still see what the tool
|
|
188
|
+
* is currently writing while the volatile block stays short enough not to strand
|
|
189
|
+
* its top above the viewport. `Ctrl+O` widens the bounded window, but does not
|
|
190
|
+
* fully uncap live tool previews for the same reason.
|
|
191
|
+
*
|
|
192
|
+
* `prefix` (raw, e.g. a dim tree gutter) is prepended to the summary line so
|
|
193
|
+
* nested previews stay aligned.
|
|
194
|
+
*/
|
|
195
|
+
export function capPreviewLines(
|
|
196
|
+
lines: string[],
|
|
197
|
+
theme: Theme,
|
|
198
|
+
options: { max?: number; expanded?: boolean; prefix?: string } = {},
|
|
199
|
+
): string[] {
|
|
200
|
+
const max = options.max ?? (options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : CALL_PREVIEW_MAX_LINES);
|
|
201
|
+
if (lines.length <= max) return lines;
|
|
202
|
+
if (max <= 1) {
|
|
203
|
+
const hint = formatExpandHint(theme, options.expanded, true);
|
|
204
|
+
const moreLine = `${formatMoreItems(lines.length, "line")}${hint ? ` ${hint}` : ""}`;
|
|
205
|
+
return [`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`];
|
|
206
|
+
}
|
|
207
|
+
const bodyBudget = max - 1; // reserve one summary row
|
|
208
|
+
const headCount = Math.max(1, Math.ceil(bodyBudget / 2));
|
|
209
|
+
const tailCount = Math.max(1, bodyBudget - headCount);
|
|
210
|
+
const hidden = Math.max(0, lines.length - headCount - tailCount);
|
|
211
|
+
const hint = formatExpandHint(theme, options.expanded, true);
|
|
212
|
+
const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
|
|
213
|
+
return [
|
|
214
|
+
...lines.slice(0, headCount),
|
|
215
|
+
`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`,
|
|
216
|
+
...lines.slice(lines.length - tailCount),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
export function formatMeta(meta: string[], theme: Theme): string {
|
|
175
221
|
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
|
|
176
222
|
}
|
|
177
223
|
|
|
178
|
-
|
|
224
|
+
function sanitizeErrorText(message: string | undefined): string {
|
|
179
225
|
const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
|
|
180
|
-
|
|
181
|
-
|
|
226
|
+
return clean ? replaceTabs(truncateToWidth(clean, TRUNCATE_LENGTHS.LINE)) : "Unknown error";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function formatErrorMessage(message: string | undefined, theme: Theme): string {
|
|
230
|
+
return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${sanitizeErrorText(message)}`)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Error message rendered as a subordinate detail line beneath a status header
|
|
235
|
+
* that already carries the error icon (e.g. `✘ Write: <path>`). The header's
|
|
236
|
+
* icon already signals failure, so this omits the redundant error symbol and
|
|
237
|
+
* "Error:" prefix that `formatErrorMessage` adds for standalone single-line
|
|
238
|
+
* errors, indenting two columns to sit under the header title instead.
|
|
239
|
+
*/
|
|
240
|
+
export function formatErrorDetail(message: string | undefined, theme: Theme): string {
|
|
241
|
+
return ` ${theme.fg("error", sanitizeErrorText(message))}`;
|
|
182
242
|
}
|
|
183
243
|
|
|
184
244
|
export function formatEmptyMessage(message: string, theme: Theme): string {
|
package/src/tools/renderers.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { sshToolRenderer } from "./ssh";
|
|
|
31
31
|
import { todoToolRenderer } from "./todo";
|
|
32
32
|
import { writeToolRenderer } from "./write";
|
|
33
33
|
|
|
34
|
-
type ToolRenderer = {
|
|
34
|
+
export type ToolRenderer = {
|
|
35
35
|
renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
|
|
36
36
|
renderResult: (
|
|
37
37
|
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
|
@@ -40,6 +40,21 @@ type ToolRenderer = {
|
|
|
40
40
|
args?: unknown,
|
|
41
41
|
) => Component;
|
|
42
42
|
mergeCallAndResult?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* While a tool's preview is still streaming, report whether the
|
|
45
|
+
* currently-rendered preview is append-only: its rows only grow at the bottom
|
|
46
|
+
* and never re-layout above the bottom live region (a full, top-anchored
|
|
47
|
+
* content/code preview). The transcript reports this up to the TUI so a
|
|
48
|
+
* streaming preview taller than the viewport commits its scrolled-off head to
|
|
49
|
+
* native scrollback instead of dropping it (see
|
|
50
|
+
* `ToolExecutionComponent.isTranscriptBlockAppendOnly`). `result` is the
|
|
51
|
+
* latest (possibly partial) tool result, or `undefined` before one exists —
|
|
52
|
+
* `eval`/`bash` use its presence to defer committing until the streamed input
|
|
53
|
+
* (code) has finalized. Omit (or return `false`) for previews that slide a
|
|
54
|
+
* tail window or later collapse to a compact result — committing their head
|
|
55
|
+
* would strand stale rows.
|
|
56
|
+
*/
|
|
57
|
+
isStreamingPreviewAppendOnly?: (args: unknown, options: RenderResultOptions, result?: unknown) => boolean;
|
|
43
58
|
/** Render without background box, inline in the response flow */
|
|
44
59
|
inline?: boolean;
|
|
45
60
|
};
|
|
@@ -41,7 +41,7 @@ function buildReportToolIssueParams(activeBuiltinNames: readonly string[]) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function isAutoQaEnabled(settings?: Settings): boolean {
|
|
44
|
-
return $flag("PI_AUTO_QA"
|
|
44
|
+
return $flag("PI_AUTO_QA", !!settings?.get("dev.autoqa"));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// ───────────────────────────────────────────────────────────────────────────
|