@gavin-hu/my-pi-agent 0.11.4 → 0.13.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/README.md +38 -26
- package/README.zh-CN.md +33 -23
- package/extensions/bytefly/README.md +381 -225
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +52 -6
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +30 -2
- package/extensions/bytefly/footer/types.ts +12 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +15 -4
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +293 -48
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +87 -0
- package/extensions/bytefly/update/register.ts +158 -0
- package/extensions/bytefly/update/state.ts +40 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +24 -14
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +14 -5
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +45 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +56 -14
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +83 -36
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +84 -19
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +91 -2
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +246 -0
- package/extensions/plan/runtime.ts +73 -24
- package/extensions/plan/steps.ts +60 -39
- package/extensions/plan/todos.ts +29 -60
- package/extensions/plan/tools.ts +188 -244
- package/extensions/plan/tui.ts +18 -31
- package/extensions/plan/types.ts +12 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +7 -2
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +106 -41
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -3
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +46 -16
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +20 -21
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +14 -3
- package/extensions/web-access/text.ts +50 -11
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +12 -9
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +340 -46
- package/lib/todo-state.ts +62 -0
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/bytefly.schema.json +0 -785
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charset-aware body decoding shared by the web-access halves.
|
|
3
|
+
*
|
|
4
|
+
* HTTP lets a response name its encoding in `Content-Type`, and an HTML page can
|
|
5
|
+
* name one in its markup, so decoding every body as UTF-8 turns a GBK/GB18030
|
|
6
|
+
* page into replacement characters. `http.ts` honours the BOM and the declared
|
|
7
|
+
* charset; `page.ts` adds the `<meta charset>` fallback for HTML. An unknown
|
|
8
|
+
* label falls back to UTF-8 instead of failing the fetch.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FALLBACK_CHARSET = "utf-8";
|
|
12
|
+
|
|
13
|
+
/** How much of an HTML document is scanned for a `<meta charset>` declaration. */
|
|
14
|
+
const SNIFF_BYTES = 4096;
|
|
15
|
+
|
|
16
|
+
/** The `charset` parameter of a content type, lowercased. */
|
|
17
|
+
export function charsetOf(contentType: string): string | undefined {
|
|
18
|
+
const match = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
|
|
19
|
+
return match?.[1]?.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The charset a leading byte-order mark declares. */
|
|
23
|
+
export function bomCharset(bytes: Uint8Array): string | undefined {
|
|
24
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
|
|
25
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
|
|
26
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The charset a document declares in its first few kilobytes. Both
|
|
32
|
+
* `<meta charset="gbk">` and `<meta http-equiv="content-type" content="text/html; charset=gbk">`
|
|
33
|
+
* are matched by the same `charset=` scan.
|
|
34
|
+
*/
|
|
35
|
+
export function sniffHtmlCharset(bytes: Uint8Array): string | undefined {
|
|
36
|
+
// Latin-1 keeps every byte addressable and ASCII tags intact.
|
|
37
|
+
const head = new TextDecoder("latin1").decode(bytes.subarray(0, SNIFF_BYTES));
|
|
38
|
+
const match = /<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_.:-]+)/i.exec(head);
|
|
39
|
+
return match?.[1]?.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The label type `TextDecoder` accepts; a runtime label is validated by the constructor. */
|
|
43
|
+
type DecoderLabel = ConstructorParameters<typeof TextDecoder>[0];
|
|
44
|
+
|
|
45
|
+
/** Decode bytes with `charset`, falling back to UTF-8 for an absent or unknown label. */
|
|
46
|
+
export function decodeBytes(bytes: Uint8Array, charset?: string): string {
|
|
47
|
+
if (!charset) return new TextDecoder(FALLBACK_CHARSET).decode(bytes);
|
|
48
|
+
try {
|
|
49
|
+
return new TextDecoder(charset as DecoderLabel).decode(bytes);
|
|
50
|
+
} catch {
|
|
51
|
+
return new TextDecoder(FALLBACK_CHARSET).decode(bytes);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decode an HTML body: BOM first, then the declared charset, then its own `<meta>`, then UTF-8. */
|
|
56
|
+
export function decodeHtml(bytes: Uint8Array, contentType: string): string {
|
|
57
|
+
return decodeBytes(bytes, bomCharset(bytes) ?? charsetOf(contentType) ?? sniffHtmlCharset(bytes));
|
|
58
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* model already receives, not the raw HTML.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { charLength, sliceChars } from "../text.ts";
|
|
17
17
|
|
|
18
18
|
export type FindMode = "insensitive" | "exact" | "fuzzy";
|
|
19
19
|
|
|
@@ -46,12 +46,12 @@ export function toCodePointOffset(text: string, utf16Index: number): number {
|
|
|
46
46
|
return offset;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function passageAround(
|
|
49
|
+
function passageAround(text: string, start: number, end: number, contextChars: number, total: number): string {
|
|
50
50
|
const from = Math.max(0, start - contextChars);
|
|
51
|
-
const to = Math.min(
|
|
51
|
+
const to = Math.min(total, end + contextChars);
|
|
52
52
|
const prefix = from > 0 ? "…" : "";
|
|
53
|
-
const suffix = to <
|
|
54
|
-
return `${prefix}${
|
|
53
|
+
const suffix = to < total ? "…" : "";
|
|
54
|
+
return `${prefix}${sliceChars(text, from, to).trim()}${suffix}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function substringMatches(text: string, query: string, insensitive: boolean): Array<[number, number]> {
|
|
@@ -131,7 +131,7 @@ function fuzzyMatches(text: string, query: string): Array<[number, number]> {
|
|
|
131
131
|
*/
|
|
132
132
|
export function findPassages(text: string, queries: string[], options: FindOptions): Passage[] {
|
|
133
133
|
if (text.length === 0) return [];
|
|
134
|
-
const
|
|
134
|
+
const total = charLength(text);
|
|
135
135
|
const results: Passage[] = [];
|
|
136
136
|
const seen = new Set<string>();
|
|
137
137
|
|
|
@@ -150,7 +150,11 @@ export function findPassages(text: string, queries: string[], options: FindOptio
|
|
|
150
150
|
if (seen.has(key)) continue;
|
|
151
151
|
seen.add(key);
|
|
152
152
|
const endOffset = toCodePointOffset(text, end);
|
|
153
|
-
results.push({
|
|
153
|
+
results.push({
|
|
154
|
+
query: trimmed,
|
|
155
|
+
offset,
|
|
156
|
+
passage: passageAround(text, offset, endOffset, options.contextChars, total),
|
|
157
|
+
});
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* and the truncation note — within `maxChars`. The header and the note are
|
|
6
6
|
* counted against the budget up front, so the "call web_fetch again with
|
|
7
7
|
* startIndex=" instruction always survives inside the limit.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* `formatMatches` renders find-in-page passages with their offsets inside the
|
|
10
|
+
* same budget: it includes as many passages as fit and says which knob to turn
|
|
11
|
+
* when it had to drop the rest, rather than returning every match the caller
|
|
12
|
+
* allowed.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import { charLength,
|
|
15
|
+
import { charLength, sliceChars, truncateChars } from "../text.ts";
|
|
12
16
|
import type { Passage } from "./find.ts";
|
|
13
17
|
|
|
14
18
|
interface PageFormatInput {
|
|
@@ -46,8 +50,7 @@ function truncationNote(nextIndex: number, total: number): string {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function formatPage(input: PageFormatInput): FormattedPage {
|
|
49
|
-
const
|
|
50
|
-
const total = points.length;
|
|
53
|
+
const total = charLength(input.text);
|
|
51
54
|
const header = pageHeader(input.title, input.finalUrl, input.status, input.contentType);
|
|
52
55
|
|
|
53
56
|
if (total === 0) {
|
|
@@ -68,15 +71,14 @@ export function formatPage(input: PageFormatInput): FormattedPage {
|
|
|
68
71
|
// `total` for both offsets gives the widest digit count) so the hint survives.
|
|
69
72
|
const plainCapacity = Math.max(1, input.maxChars - charLength(header) - 2);
|
|
70
73
|
if (input.startIndex + plainCapacity >= total) {
|
|
71
|
-
const body =
|
|
74
|
+
const body = sliceChars(input.text, input.startIndex);
|
|
72
75
|
return { body, text: `${header}\n\n${body}`, truncated: false, nextIndex: input.startIndex };
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
const reserve = charLength(header) + 2 + charLength(truncationNote(total, total));
|
|
76
79
|
const capacity = Math.max(1, input.maxChars - reserve);
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const nextIndex = input.startIndex + slicePoints.length;
|
|
80
|
+
const body = sliceChars(input.text, input.startIndex, input.startIndex + capacity);
|
|
81
|
+
const nextIndex = input.startIndex + charLength(body);
|
|
80
82
|
const text = `${header}\n\n${body}${truncationNote(nextIndex, total)}`;
|
|
81
83
|
|
|
82
84
|
return { body, text, truncated: true, nextIndex };
|
|
@@ -89,15 +91,21 @@ interface MatchFormatInput {
|
|
|
89
91
|
contentType: string;
|
|
90
92
|
matches: Passage[];
|
|
91
93
|
find: string[];
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
+
/** Budget for the whole formatted result: header, passages, and note. */
|
|
95
|
+
maxChars: number;
|
|
96
|
+
/** True when the provider capped the list at `maxMatches`. */
|
|
97
|
+
cappedByMaxMatches: boolean;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
interface FormattedMatches {
|
|
101
|
+
/** The passages that fit, rendered one per block. */
|
|
97
102
|
body: string;
|
|
103
|
+
/** Header, passages, and any truncation note. */
|
|
98
104
|
text: string;
|
|
99
|
-
/** True when the match
|
|
105
|
+
/** True when anything was hidden: the match cap, the budget, or an oversized passage. */
|
|
100
106
|
truncated: boolean;
|
|
107
|
+
/** The passages `body` and `text` actually contain. */
|
|
108
|
+
included: Passage[];
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
export function formatMatches(input: MatchFormatInput): FormattedMatches {
|
|
@@ -105,13 +113,51 @@ export function formatMatches(input: MatchFormatInput): FormattedMatches {
|
|
|
105
113
|
const header = `${pageHeader(input.title, input.finalUrl, input.status, input.contentType)}\nMatches for ${quoted}: ${input.matches.length}`;
|
|
106
114
|
|
|
107
115
|
if (input.matches.length === 0) {
|
|
108
|
-
return { body: "", text: `${header}\n\nNo matches.`, truncated: false };
|
|
116
|
+
return { body: "", text: `${header}\n\nNo matches.`, truncated: false, included: [] };
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
const blocks = input.matches.map((match, index) => `${index + 1}. [offset ${match.offset}] ${match.passage}`);
|
|
120
|
+
|
|
121
|
+
// Add whole blocks while they fit; the first one is kept even when oversized.
|
|
122
|
+
const shown: string[] = [];
|
|
123
|
+
let size = charLength(header) + 2;
|
|
124
|
+
for (const block of blocks) {
|
|
125
|
+
const length = charLength(block);
|
|
126
|
+
if (shown.length > 0 && size + length + 2 > input.maxChars) break;
|
|
127
|
+
shown.push(block);
|
|
128
|
+
size += length + 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const noteFor = (count: number): string => {
|
|
132
|
+
if (count < blocks.length) {
|
|
133
|
+
return `(showing ${count} of ${blocks.length} matches within ${input.maxChars} characters; narrow find or lower contextChars)`;
|
|
134
|
+
}
|
|
135
|
+
if (input.cappedByMaxMatches) return `(first ${count} matches; raise maxMatches for more)`;
|
|
136
|
+
return "";
|
|
137
|
+
};
|
|
138
|
+
const render = (count: number, note: string): string => {
|
|
139
|
+
const base = `${header}\n\n${shown.slice(0, count).join("\n\n")}`;
|
|
140
|
+
return note ? `${base}\n\n${note}` : base;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The note must survive the budget too, so drop trailing passages until it fits.
|
|
144
|
+
let count = shown.length;
|
|
145
|
+
let note = noteFor(count);
|
|
146
|
+
while (count > 1 && charLength(render(count, note)) > input.maxChars) {
|
|
147
|
+
count--;
|
|
148
|
+
note = noteFor(count);
|
|
149
|
+
}
|
|
150
|
+
if (note && charLength(render(count, note)) > input.maxChars) note = "";
|
|
151
|
+
|
|
152
|
+
let text = render(count, note);
|
|
153
|
+
const overflow = charLength(text) > input.maxChars;
|
|
154
|
+
if (overflow) text = truncateChars(text, input.maxChars);
|
|
155
|
+
|
|
156
|
+
const body = shown.slice(0, count).join("\n\n");
|
|
157
|
+
return {
|
|
158
|
+
body,
|
|
159
|
+
text,
|
|
160
|
+
truncated: count < blocks.length || input.cappedByMaxMatches || overflow,
|
|
161
|
+
included: input.matches.slice(0, count),
|
|
162
|
+
};
|
|
117
163
|
}
|
|
@@ -13,6 +13,7 @@ import type { WebFetchConfig } from "./config.ts";
|
|
|
13
13
|
import { extractReadable } from "./extract.ts";
|
|
14
14
|
import { extractPdfText, type PdfExtractor } from "./pdf.ts";
|
|
15
15
|
import { createFetchRunner, type HttpRunner, type HttpResponse } from "../http.ts";
|
|
16
|
+
import { decodeHtml } from "../charset.ts";
|
|
16
17
|
import { assertAllowedUrl } from "./ssrf.ts";
|
|
17
18
|
import type { FetchRequest } from "./types.ts";
|
|
18
19
|
|
|
@@ -149,7 +150,10 @@ export async function runFetch(
|
|
|
149
150
|
text = `(binary content: ${response.contentType || "application/pdf"}, ${response.sizeBytes} bytes; PDF text extraction failed: ${reason})`;
|
|
150
151
|
}
|
|
151
152
|
} else if (isHtmlType(response.contentType)) {
|
|
152
|
-
|
|
153
|
+
// `bytes` carries the raw body, so a page that declares GBK in a `<meta>` tag
|
|
154
|
+
// but not in the header is still decoded correctly.
|
|
155
|
+
const html = response.bytes ? decodeHtml(response.bytes, response.contentType) : response.body;
|
|
156
|
+
const page = extractReadable(html, response.finalUrl);
|
|
153
157
|
title = page.title;
|
|
154
158
|
text = page.text;
|
|
155
159
|
} else if (isTextualType(response.contentType)) {
|
|
@@ -27,7 +27,7 @@ import { charLength } from "../text.ts";
|
|
|
27
27
|
import { oneLine } from "../transcript.ts";
|
|
28
28
|
import { createPageCache, type PageCache } from "./cache.ts";
|
|
29
29
|
import type { WebFetchConfig } from "./config.ts";
|
|
30
|
-
import { findPassages } from "./find.ts";
|
|
30
|
+
import { findPassages, type Passage } from "./find.ts";
|
|
31
31
|
import { formatMatches, formatPage } from "./format.ts";
|
|
32
32
|
import { runFetch, type FetchDeps, type PageResult } from "./page.ts";
|
|
33
33
|
import { resolveRequest, WebFetchOutput, WebFetchParams, type FetchResponse, type WebFetchArgs } from "./schema.ts";
|
|
@@ -85,29 +85,33 @@ async function loadPage(
|
|
|
85
85
|
function pageResult(loaded: LoadedPage, request: FetchRequest): { response: FetchResponse; text: string } {
|
|
86
86
|
const { page, cached, fetchedAt } = loaded;
|
|
87
87
|
const finding = request.find.length > 0;
|
|
88
|
-
const matches = finding
|
|
89
|
-
? findPassages(page.text, request.find, {
|
|
90
|
-
mode: request.mode,
|
|
91
|
-
contextChars: request.contextChars,
|
|
92
|
-
maxMatches: request.maxMatches + 1,
|
|
93
|
-
})
|
|
94
|
-
: [];
|
|
95
|
-
// Request one extra so a full list proves more hits exist; only the cap is shown.
|
|
96
|
-
const matchesTruncated = finding && matches.length > request.maxMatches;
|
|
97
|
-
const visibleMatches = matchesTruncated ? matches.slice(0, request.maxMatches) : matches;
|
|
98
88
|
|
|
99
89
|
let formatted: { body: string; text: string; truncated: boolean };
|
|
100
90
|
let nextIndex: number;
|
|
91
|
+
let matches: Passage[] = [];
|
|
92
|
+
let matchesTruncated = false;
|
|
93
|
+
|
|
101
94
|
if (finding) {
|
|
102
|
-
|
|
95
|
+
// Request one extra so a full list proves more hits exist; only the cap is shown.
|
|
96
|
+
const found = findPassages(page.text, request.find, {
|
|
97
|
+
mode: request.mode,
|
|
98
|
+
contextChars: request.contextChars,
|
|
99
|
+
maxMatches: request.maxMatches + 1,
|
|
100
|
+
});
|
|
101
|
+
const cappedByMaxMatches = found.length > request.maxMatches;
|
|
102
|
+
const rendered = formatMatches({
|
|
103
103
|
finalUrl: page.finalUrl,
|
|
104
104
|
title: page.title,
|
|
105
105
|
status: page.status,
|
|
106
106
|
contentType: page.contentType,
|
|
107
|
-
matches:
|
|
107
|
+
matches: cappedByMaxMatches ? found.slice(0, request.maxMatches) : found,
|
|
108
108
|
find: request.find,
|
|
109
|
-
|
|
109
|
+
maxChars: request.maxChars,
|
|
110
|
+
cappedByMaxMatches,
|
|
110
111
|
});
|
|
112
|
+
formatted = rendered;
|
|
113
|
+
matches = rendered.included;
|
|
114
|
+
matchesTruncated = rendered.truncated;
|
|
111
115
|
// Find results are not paged, so the caller keeps reading from the same offset.
|
|
112
116
|
nextIndex = request.startIndex;
|
|
113
117
|
} else {
|
|
@@ -136,7 +140,7 @@ function pageResult(loaded: LoadedPage, request: FetchRequest): { response: Fetc
|
|
|
136
140
|
nextIndex,
|
|
137
141
|
truncated: formatted.truncated,
|
|
138
142
|
cached,
|
|
139
|
-
matches
|
|
143
|
+
matches,
|
|
140
144
|
matchesTruncated,
|
|
141
145
|
fetchedAt,
|
|
142
146
|
error: "",
|
|
@@ -191,12 +195,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
|
|
|
191
195
|
],
|
|
192
196
|
parameters: WebFetchParams,
|
|
193
197
|
outputSchema: WebFetchOutput,
|
|
194
|
-
|
|
195
|
-
defaultActive: true,
|
|
196
|
-
annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
197
|
-
// The page cache is shared in-memory state created per registration, so
|
|
198
|
-
// sibling calls must not run concurrently (matches web_search's throttle).
|
|
199
|
-
executionMode: "sequential",
|
|
198
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
200
199
|
|
|
201
200
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
202
201
|
const config = loadFetchConfig(ctx.cwd);
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* No `curl`, no dependencies: one `fetch` call per request (with optional
|
|
5
5
|
* manual redirect handling) with a combined caller-signal and timeout, optional
|
|
6
|
-
* request body, and a size cap
|
|
7
|
-
* network.
|
|
6
|
+
* request body, and a size cap enforced while the body is read. `HttpRunner` is
|
|
7
|
+
* the seam tests use to avoid the network.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { bomCharset, charsetOf, decodeBytes } from "./charset.ts";
|
|
11
|
+
|
|
10
12
|
/** A request to send. */
|
|
11
13
|
interface HttpRequest {
|
|
12
14
|
url: string;
|
|
@@ -15,7 +17,7 @@ interface HttpRequest {
|
|
|
15
17
|
/** Raw request body (for example a JSON-RPC payload). */
|
|
16
18
|
body?: string;
|
|
17
19
|
timeoutMs: number;
|
|
18
|
-
/** Maximum response size in bytes
|
|
20
|
+
/** Maximum response size in bytes, enforced while the body is read. */
|
|
19
21
|
maxBytes?: number;
|
|
20
22
|
/** Redirect handling: `"follow"` (default) or `"manual"` to inspect each hop. */
|
|
21
23
|
redirect?: "follow" | "manual";
|
|
@@ -63,6 +65,48 @@ export class HttpUnavailableError extends Error {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Concatenate chunks into one buffer. `total` is the caller's running byte count,
|
|
70
|
+
* so the allocation happens once rather than growing an array per chunk.
|
|
71
|
+
*/
|
|
72
|
+
function concatChunks(chunks: Uint8Array[], total: number): Uint8Array {
|
|
73
|
+
const merged = new Uint8Array(total);
|
|
74
|
+
let offset = 0;
|
|
75
|
+
for (const chunk of chunks) {
|
|
76
|
+
merged.set(chunk, offset);
|
|
77
|
+
offset += chunk.byteLength;
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read a response body to completion, aborting the read as soon as it exceeds
|
|
84
|
+
* `maxBytes`. `response.arrayBuffer()` would buffer a whole chunked or
|
|
85
|
+
* compressed response first, which is the case the cap exists to prevent.
|
|
86
|
+
*/
|
|
87
|
+
async function readCappedBody(response: Response, maxBytes?: number): Promise<Uint8Array> {
|
|
88
|
+
if (!response.body) {
|
|
89
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
90
|
+
if (maxBytes !== undefined && bytes.byteLength > maxBytes) throw new HttpTooLargeError(maxBytes);
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reader = response.body.getReader();
|
|
95
|
+
const chunks: Uint8Array[] = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (;;) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done) break;
|
|
100
|
+
total += value.byteLength;
|
|
101
|
+
if (maxBytes !== undefined && total > maxBytes) {
|
|
102
|
+
await reader.cancel().catch(() => undefined);
|
|
103
|
+
throw new HttpTooLargeError(maxBytes);
|
|
104
|
+
}
|
|
105
|
+
chunks.push(value);
|
|
106
|
+
}
|
|
107
|
+
return concatChunks(chunks, total);
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
/**
|
|
67
111
|
* Create a runner backed by `fetch`.
|
|
68
112
|
*
|
|
@@ -103,11 +147,8 @@ export function createFetchRunner(fetchImpl: typeof fetch = globalThis.fetch): H
|
|
|
103
147
|
throw new HttpTooLargeError(request.maxBytes);
|
|
104
148
|
}
|
|
105
149
|
|
|
106
|
-
const bytes =
|
|
107
|
-
|
|
108
|
-
throw new HttpTooLargeError(request.maxBytes);
|
|
109
|
-
}
|
|
110
|
-
const body = new TextDecoder().decode(bytes);
|
|
150
|
+
const bytes = await readCappedBody(response, request.maxBytes);
|
|
151
|
+
const body = decodeBytes(bytes, bomCharset(bytes) ?? charsetOf(contentType));
|
|
111
152
|
const location = response.headers.get("location") ?? undefined;
|
|
112
153
|
|
|
113
154
|
return {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `web_search` resolved to, published for `doctor`.
|
|
3
|
+
*
|
|
4
|
+
* The provider registry, each provider's `isConfigured`, and the `auto`
|
|
5
|
+
* resolution rule all live in this extension, so this module is where the facts
|
|
6
|
+
* for a diagnostic come from. It publishes a summary only — the key itself never
|
|
7
|
+
* leaves the extension, just the variable name and whether a literal key exists.
|
|
8
|
+
*
|
|
9
|
+
* `endpoint` is deliberately null for the providers without a configurable
|
|
10
|
+
* endpoint: probing an API host would report a 401 as a failure for a provider
|
|
11
|
+
* that works, and there is nothing for the user to fix.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WebSearchProviderEvent } from "../../../lib/events.ts";
|
|
15
|
+
import type { WebSearchConfig } from "./config.ts";
|
|
16
|
+
import { DEFAULT_PROVIDER_ID, resolveProvider } from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The facts for one session's config. `reason` distinguishes an explicit choice
|
|
20
|
+
* from the auto rule, so a report can tell "you asked for this and it is not
|
|
21
|
+
* configured" from "this is the keyless fallback".
|
|
22
|
+
*/
|
|
23
|
+
export function providerFacts(config: WebSearchConfig): WebSearchProviderEvent {
|
|
24
|
+
const provider = resolveProvider(config);
|
|
25
|
+
// `auto` only leaves the keyless default when another provider is configured,
|
|
26
|
+
// so the resolved id tells the two apart.
|
|
27
|
+
const reason: WebSearchProviderEvent["reason"] =
|
|
28
|
+
config.provider !== "auto" ? "explicit" : provider.id === DEFAULT_PROVIDER_ID ? "default" : "configured";
|
|
29
|
+
return {
|
|
30
|
+
provider: provider.id,
|
|
31
|
+
reason,
|
|
32
|
+
configured: provider.isConfigured(config),
|
|
33
|
+
// Only SearXNG's base URL is a setting a user can get wrong.
|
|
34
|
+
endpoint: provider.id === "searxng" && config.endpoint !== "" ? config.endpoint : null,
|
|
35
|
+
apiKeyEnv: config.apiKeyEnv || null,
|
|
36
|
+
keyConfigured: config.apiKey.trim() !== "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { WEB_SEARCH_PROVIDER_CHANNEL } from "../../../lib/events.ts";
|
|
11
12
|
import { loadSearchConfig } from "../config.ts";
|
|
12
13
|
import { oneLine } from "../transcript.ts";
|
|
13
14
|
import { reuseText } from "../../../lib/tui/chrome.ts";
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from "../../../lib/tui/tool.ts";
|
|
22
23
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
23
24
|
import { expandHint, SEPARATORS } from "../../../lib/tui/glyphs.ts";
|
|
25
|
+
import { providerFacts } from "./facts.ts";
|
|
24
26
|
import { formatResults } from "./format.ts";
|
|
25
27
|
import { createThrottle, runSearch, type SearchDeps } from "./search.ts";
|
|
26
28
|
import { resolveRequest, WebSearchOutput, WebSearchParams, type SearchResponse, type WebSearchArgs } from "./schema.ts";
|
|
@@ -32,6 +34,17 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
|
|
|
32
34
|
// without any module-level state.
|
|
33
35
|
const runDeps: SearchDeps = { http: deps.http, throttle: deps.throttle ?? createThrottle() };
|
|
34
36
|
|
|
37
|
+
// Publish what this session resolved to, so `doctor` can report the provider,
|
|
38
|
+
// its key, and its endpoint without importing this extension. Accounting only:
|
|
39
|
+
// a bus listener that throws must not break the tool.
|
|
40
|
+
pi.on("session_start", (_event, ctx) => {
|
|
41
|
+
try {
|
|
42
|
+
pi.events.emit(WEB_SEARCH_PROVIDER_CHANNEL, providerFacts(loadSearchConfig(ctx.cwd)));
|
|
43
|
+
} catch {
|
|
44
|
+
// Diagnostics are best-effort.
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
pi.registerTool({
|
|
36
49
|
name: TOOL_NAME,
|
|
37
50
|
label: "Web search",
|
|
@@ -46,9 +59,7 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
|
|
|
46
59
|
],
|
|
47
60
|
parameters: WebSearchParams,
|
|
48
61
|
outputSchema: WebSearchOutput,
|
|
49
|
-
|
|
50
|
-
defaultActive: true,
|
|
51
|
-
annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
|
|
62
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
52
63
|
executionMode: "sequential",
|
|
53
64
|
|
|
54
65
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -3,24 +3,63 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Character budgets are counted in code points, not UTF-16 units, so a slice
|
|
5
5
|
* never splits a CJK character or an emoji, and a declared limit is honoured for
|
|
6
|
-
* the text the model sees.
|
|
7
|
-
*
|
|
6
|
+
* the text the model sees. Everything here walks the string in place: a
|
|
7
|
+
* multi-megabyte page is never materialised as an array of one-character
|
|
8
|
+
* strings, which is what `Array.from(text)` would cost on every call.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
export function toChars(text: string): string[] {
|
|
12
|
-
return Array.from(text);
|
|
13
|
-
}
|
|
11
|
+
const SURROGATE = /[\uD800-\uDFFF]/;
|
|
14
12
|
|
|
15
13
|
/** Length of text in code points. */
|
|
16
14
|
export function charLength(text: string): number {
|
|
17
|
-
|
|
15
|
+
// A native scan with no allocation covers the common all-BMP page.
|
|
16
|
+
if (!SURROGATE.test(text)) return text.length;
|
|
17
|
+
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
const code = text.charCodeAt(i);
|
|
21
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
22
|
+
const next = text.charCodeAt(i + 1);
|
|
23
|
+
if (next >= 0xdc00 && next <= 0xdfff) i++;
|
|
24
|
+
}
|
|
25
|
+
count++;
|
|
26
|
+
}
|
|
27
|
+
return count;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Slice text by code-point offsets, like `Array.from(text).slice(from, to).join("")`
|
|
32
|
+
* without building the array. Out-of-range offsets clamp, and `to` defaults to
|
|
33
|
+
* the end of the string.
|
|
34
|
+
*/
|
|
35
|
+
export function sliceChars(text: string, from: number, to: number = Number.POSITIVE_INFINITY): string {
|
|
36
|
+
const start = Math.max(0, Math.floor(from));
|
|
37
|
+
const end = Math.max(start, Math.floor(to));
|
|
38
|
+
if (start === 0 && end >= text.length) return text;
|
|
39
|
+
|
|
40
|
+
let startIndex = text.length;
|
|
41
|
+
let endIndex = text.length;
|
|
42
|
+
let codePoint = 0;
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
if (codePoint === start && startIndex === text.length) startIndex = i;
|
|
45
|
+
if (codePoint === end) {
|
|
46
|
+
endIndex = i;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const code = text.charCodeAt(i);
|
|
50
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
51
|
+
const next = text.charCodeAt(i + 1);
|
|
52
|
+
if (next >= 0xdc00 && next <= 0xdfff) i++;
|
|
53
|
+
}
|
|
54
|
+
codePoint++;
|
|
55
|
+
}
|
|
56
|
+
return text.slice(startIndex, endIndex);
|
|
18
57
|
}
|
|
19
58
|
|
|
20
59
|
/** Truncate to `maxChars` code points, appending `marker` when anything was cut. */
|
|
21
60
|
export function truncateChars(text: string, maxChars: number, marker = "…"): string {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (maxChars <=
|
|
25
|
-
return `${
|
|
61
|
+
if (charLength(text) <= maxChars) return text;
|
|
62
|
+
const markerLength = charLength(marker);
|
|
63
|
+
if (maxChars <= markerLength) return marker;
|
|
64
|
+
return `${sliceChars(text, 0, maxChars - markerLength)}${marker}`;
|
|
26
65
|
}
|
|
@@ -272,7 +272,7 @@ overridden by the project `<cwd>/.pi/bytefly.json` (project wins):
|
|
|
272
272
|
"maxInboundChars": 8000, // inbound text longer than this is truncated
|
|
273
273
|
"typingIndicator": true, // show typing while a turn is generated
|
|
274
274
|
"remotePrompts": true, // answer interactive dialogs on WeChat during a WeChat turn
|
|
275
|
-
"pollHoldMs":
|
|
275
|
+
// "pollHoldMs": 3000 // experimental: request a shorter long-poll hold; unset by default
|
|
276
276
|
}
|
|
277
277
|
```
|
|
278
278
|
|
|
@@ -392,8 +392,9 @@ never touches the stored cursor.
|
|
|
392
392
|
supplies a `context_token` only then).
|
|
393
393
|
- Slash commands are fixed built-ins; there is no user-defined snippet or action
|
|
394
394
|
list yet, and `/new`, `/stop`, `/status`, and `/help` are registered.
|
|
395
|
-
-
|
|
396
|
-
|
|
395
|
+
- Pause is runtime-local. `/wechat pause` stops polling and releases the lock,
|
|
396
|
+
but a reload, `/new`, or restart builds a fresh running bridge; only
|
|
397
|
+
`/wechat logout` stops it persistently (and discards the credentials).
|
|
397
398
|
|
|
398
399
|
## Non-goals
|
|
399
400
|
|
|
@@ -406,7 +407,7 @@ sessions are in-process).
|
|
|
406
407
|
| Integration point | Detail |
|
|
407
408
|
|---|---|
|
|
408
409
|
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
409
|
-
| Tools | `send_wechat` — owner-only proactive text; registered at factory time with
|
|
410
|
+
| Tools | `send_wechat` — owner-only proactive text; registered at factory time with direct exposure (the default), `executionMode: "sequential"`, `openWorldHint: true`. |
|
|
410
411
|
| Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
|
|
411
412
|
| Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
|
|
412
413
|
| Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
|
|
@@ -68,8 +68,6 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
68
68
|
],
|
|
69
69
|
parameters: WechatSendParams,
|
|
70
70
|
outputSchema: WechatSendResult,
|
|
71
|
-
exposure: "direct",
|
|
72
|
-
defaultActive: true,
|
|
73
71
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
74
72
|
executionMode: "sequential",
|
|
75
73
|
|