allaboard 0.5.0 → 0.6.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 +9 -7
- package/dist/agent/claude/backend.js +73 -2
- package/dist/agent/claude/capabilities.js +4 -0
- package/dist/agent/claude/module.js +55 -0
- package/dist/agent/claude/translate.js +6 -2
- package/dist/agent/claude/trust.js +22 -0
- package/dist/chat/seam.js +3 -0
- package/dist/chat/slack/app/app.js +52 -4
- package/dist/chat/slack/app/delete-thread.js +90 -0
- package/dist/chat/slack/app/guards.js +44 -0
- package/dist/chat/slack/app/home-controls.js +8 -1
- package/dist/chat/slack/channels.js +23 -5
- package/dist/chat/slack/delete-thread.js +37 -0
- package/dist/chat/slack/delete.js +13 -7
- package/dist/chat/slack/home.js +194 -35
- package/dist/chat/slack/module.js +64 -14
- package/dist/chat/slack/onboarding.js +159 -0
- package/dist/chat/slack/reply/blocks.js +27 -11
- package/dist/chat/slack/reply/sinks.js +3 -1
- package/dist/chat/slack/tag.js +317 -0
- package/dist/chat/slack/texts.js +8 -0
- package/dist/chat/slack/thread.js +62 -2
- package/dist/chat/slack/tokens.js +15 -0
- package/dist/cli.js +43 -4
- package/dist/core/cleanup.js +66 -3
- package/dist/core/commands.js +40 -0
- package/dist/core/config.js +19 -11
- package/dist/core/files/listing.js +19 -0
- package/dist/core/flows/bind.js +29 -6
- package/dist/core/flows/flows.js +2 -0
- package/dist/core/flows/gone.js +39 -0
- package/dist/core/flows/inbound.js +3 -0
- package/dist/core/flows/messages.js +4 -2
- package/dist/core/flows/status.js +98 -48
- package/dist/core/flows/words.js +80 -1
- package/dist/core/guide.js +2 -0
- package/dist/core/port/cleanup.js +1 -0
- package/dist/core/port.js +1 -0
- package/dist/core/sessions/directory.js +47 -8
- package/dist/core/sessions/manager.js +32 -17
- package/dist/core/sessions/quote.js +57 -0
- package/dist/core/sessions/session.js +324 -35
- package/dist/core/sessions/title.js +98 -0
- package/dist/core/state.js +25 -0
- package/dist/core/texts.js +58 -3
- package/dist/core/wizard.js +306 -0
- package/dist/daemon.js +23 -11
- package/dist/host/program.js +22 -7
- package/dist/host/prompter.js +61 -0
- package/dist/module-setup.js +6 -0
- package/dist/version.js +4 -0
- package/package.json +5 -2
- package/slack-app-manifest.json +2 -1
package/dist/core/flows/words.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A word of the daemon's own: what each does, and where it is answered. Typed in the channel (or
|
|
3
|
+
* in a thread that holds no session) it is answered by a normal post in the channel; typed
|
|
4
|
+
* inside a session's thread it is answered for the owner alone under the owner's message, or by
|
|
5
|
+
* a mark on it, so nothing rings a phone; `!stop` is answered by a post that stays in the
|
|
6
|
+
* thread, and `!open` shows its files there.
|
|
7
|
+
*/
|
|
8
|
+
import { ChatError } from "../../chat/seam.js";
|
|
9
|
+
import { errorName } from "../../values.js";
|
|
1
10
|
import { helpText, WORD } from "../commands.js";
|
|
2
11
|
import { guideText } from "../guide.js";
|
|
3
12
|
import { BypassUnavailable } from "../sessions/errors.js";
|
|
4
13
|
import * as texts from "../texts.js";
|
|
5
14
|
import { fill, named } from "../texts.js";
|
|
6
15
|
import { channelStatus } from "./status.js";
|
|
16
|
+
import { logger } from "./talk.js";
|
|
7
17
|
export function wordFlow(deps, talk, others) {
|
|
8
|
-
const { chat, agent, sessions, names } = deps;
|
|
18
|
+
const { chat, agent, sessions, state, names } = deps;
|
|
9
19
|
const status = channelStatus(deps, talk);
|
|
10
20
|
// The words this agent and this chat cannot serve: each is refused when typed, and `!help`
|
|
11
21
|
// does not list it.
|
|
@@ -16,10 +26,35 @@ export function wordFlow(deps, talk, others) {
|
|
|
16
26
|
unserved.add(WORD.resume);
|
|
17
27
|
if (!chat.capabilities.fileUpload)
|
|
18
28
|
unserved.add(WORD.open);
|
|
29
|
+
if (!chat.capabilities.threadTags) {
|
|
30
|
+
unserved.add(WORD.tag);
|
|
31
|
+
unserved.add(WORD.untag);
|
|
32
|
+
}
|
|
33
|
+
if (!chat.capabilities.threadDelete)
|
|
34
|
+
unserved.add(WORD["delete-thread"]);
|
|
19
35
|
// A daemon built without a way to update itself offers no `!allaboard`.
|
|
20
36
|
const allaboard = others.allaboard;
|
|
21
37
|
if (allaboard === undefined)
|
|
22
38
|
unserved.add(WORD.allaboard);
|
|
39
|
+
/**
|
|
40
|
+
* The agent's own tag for the session, once the chat shows the change. A thread whose session
|
|
41
|
+
* has no id yet has nothing to tag, and an agent that declares no `sessionTags` keeps none; a
|
|
42
|
+
* failure leaves the chat's change standing and is told to the owner alone.
|
|
43
|
+
*/
|
|
44
|
+
async function mirrorTag(ref, session, tag) {
|
|
45
|
+
if (!agent.capabilities.sessionTags || agent.tagSession === undefined)
|
|
46
|
+
return;
|
|
47
|
+
const id = state.thread(ref.channel, ref.thread)?.sessionId;
|
|
48
|
+
if (id === null || id === undefined)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
await agent.tagSession(session.directory, id, tag);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
logger.warning(`could not tag the agent's session of ${ref.channel}/${ref.thread}: ${errorName(error)}`);
|
|
55
|
+
await talk.tell(ref, named(texts.TAG_AGENT_FAILED, names));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
23
58
|
return async (ref, message, command, { session, outside }) => {
|
|
24
59
|
const { channel, thread } = ref;
|
|
25
60
|
const where = session === null ? null : thread;
|
|
@@ -50,6 +85,8 @@ export function wordFlow(deps, talk, others) {
|
|
|
50
85
|
backgroundTasks: have.backgroundTasks,
|
|
51
86
|
sessionListing: have.sessionListing,
|
|
52
87
|
fileUpload: chat.capabilities.fileUpload,
|
|
88
|
+
threadTags: chat.capabilities.threadTags,
|
|
89
|
+
threadDelete: chat.capabilities.threadDelete,
|
|
53
90
|
updates: allaboard !== undefined,
|
|
54
91
|
};
|
|
55
92
|
await talk.say(channel, where, guideText(names, offered), { ownerOnly: quiet });
|
|
@@ -139,6 +176,48 @@ export function wordFlow(deps, talk, others) {
|
|
|
139
176
|
await chat.openFile(ref, session.directory, command.target);
|
|
140
177
|
}
|
|
141
178
|
return;
|
|
179
|
+
case "tag":
|
|
180
|
+
case "untag": {
|
|
181
|
+
if (session === null) {
|
|
182
|
+
await talk.inChannel(channel, texts.TAG_TOP_LEVEL);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!chat.capabilities.threadTags || chat.tagThread === undefined) {
|
|
186
|
+
await talk.tell(ref, named(texts.TAG_UNAVAILABLE, names));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const tag = command.kind === "tag" ? command.name : null;
|
|
190
|
+
// The chat first, since it is what the owner sees. Its refusal is the chat's, not the
|
|
191
|
+
// agent's, which the reply to any other failure of a word would name.
|
|
192
|
+
try {
|
|
193
|
+
await chat.tagThread(ref, tag);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!(error instanceof ChatError))
|
|
197
|
+
throw error;
|
|
198
|
+
await talk.tell(ref, fill(named(texts.TAG_FAILED, names), { error: error.name }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await mirrorTag(ref, session, tag);
|
|
202
|
+
await talk.acknowledge(ref, message);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case "delete-thread":
|
|
206
|
+
if (session === null) {
|
|
207
|
+
await talk.inChannel(channel, texts.DELETE_THREAD_TOP_LEVEL);
|
|
208
|
+
}
|
|
209
|
+
else if (!chat.capabilities.threadDelete || chat.deleteThread === undefined) {
|
|
210
|
+
await talk.tell(ref, named(texts.DELETE_THREAD_UNAVAILABLE, names));
|
|
211
|
+
}
|
|
212
|
+
else if (session.inUse) {
|
|
213
|
+
// Refused before the owner is asked; the delete checks again on their yes, since the
|
|
214
|
+
// thread may have started working meanwhile.
|
|
215
|
+
await talk.tell(ref, texts.DELETE_THREAD_BUSY);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
await chat.deleteThread(ref);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
142
221
|
case "allaboard":
|
|
143
222
|
if (allaboard === undefined) {
|
|
144
223
|
await showHelp("");
|
package/dist/core/guide.js
CHANGED
|
@@ -43,6 +43,8 @@ export function guideText(names, offered) {
|
|
|
43
43
|
sessions,
|
|
44
44
|
...(offered.fileUpload ? [say(texts.GUIDE_FILES)] : []),
|
|
45
45
|
...(offered.bypass ? [say(texts.GUIDE_BYPASS)] : []),
|
|
46
|
+
...(offered.threadTags ? [say(texts.GUIDE_TAGS)] : []),
|
|
47
|
+
...(offered.threadDelete ? [say(texts.GUIDE_DELETE)] : []),
|
|
46
48
|
...(offered.updates ? [texts.GUIDE_UPDATES] : []),
|
|
47
49
|
texts.GUIDE_END,
|
|
48
50
|
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/core/port.js
CHANGED
|
@@ -4,6 +4,7 @@ import { opendir, stat } from "node:fs/promises";
|
|
|
4
4
|
import { isAbsolute, sep } from "node:path";
|
|
5
5
|
import { shellQuote } from "../../text.js";
|
|
6
6
|
import { errorCode } from "../../values.js";
|
|
7
|
+
import { untrustedRepositories } from "../files/listing.js";
|
|
7
8
|
import { ABSENT, expandUser, resolvePath } from "../paths.js";
|
|
8
9
|
import * as texts from "../texts.js";
|
|
9
10
|
import { DirectoryMissing, DirectoryUnreadable, DirectoryUntrusted } from "./errors.js";
|
|
@@ -22,6 +23,18 @@ export function trustedBy(agent) {
|
|
|
22
23
|
export function repositoryBy(agent) {
|
|
23
24
|
return async (folder) => agent.capabilities.folderTrust ? agent.trustedRepository(folder) : null;
|
|
24
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* What tells the owner which repositories below `folder` still need `agent`'s trust before the
|
|
28
|
+
* footer and `!open` read them (issue #247), each with the shell line that asks for it at its
|
|
29
|
+
* key; nothing for an agent that keeps no record of trust. Rejects as the lookups do.
|
|
30
|
+
*/
|
|
31
|
+
export async function untrustedNotice(agent, folder, names, escapeMarkup) {
|
|
32
|
+
const template = agent.words.trust;
|
|
33
|
+
if (!agent.capabilities.folderTrust || template === null)
|
|
34
|
+
return [];
|
|
35
|
+
const found = await untrustedRepositories(folder, repositoryBy(agent), (directory) => agent.untrustedRepository(directory));
|
|
36
|
+
return untrustedLines(template, found.map((repository) => repository.key), names, escapeMarkup);
|
|
37
|
+
}
|
|
25
38
|
/**
|
|
26
39
|
* Rejects with the `DirectoryUnavailable` that keeps a session from starting in `directory`,
|
|
27
40
|
* the first found in the order the owner can act on: missing, unreadable, untrusted.
|
|
@@ -52,7 +65,10 @@ export async function checkDirectory(directory, trusted, names) {
|
|
|
52
65
|
function under(path, root) {
|
|
53
66
|
return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
54
67
|
}
|
|
55
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The real directory `raw` names when it exists under `root`, or why it does not; guards
|
|
70
|
+
* against a typo only.
|
|
71
|
+
*/
|
|
56
72
|
export function resolveDirectory(raw, root) {
|
|
57
73
|
// A relative path is read under the root: the daemon's own working directory means nothing
|
|
58
74
|
// to the owner (launchd starts it in `/`).
|
|
@@ -60,14 +76,18 @@ export function resolveDirectory(raw, root) {
|
|
|
60
76
|
try {
|
|
61
77
|
// Joined as text: the disk follows a link before a `..`.
|
|
62
78
|
const path = resolvePath(isAbsolute(written) ? written : `${root}/${written}`);
|
|
63
|
-
if (!
|
|
64
|
-
return
|
|
79
|
+
if (!under(path, root))
|
|
80
|
+
return { refused: "outside" };
|
|
81
|
+
if (!statSync(path).isDirectory())
|
|
82
|
+
return { refused: "file" };
|
|
65
83
|
// The kernel is asked too, since a session runs in the folder it reaches: whatever the
|
|
66
84
|
// reading above made of the spelling, that folder lies under the root or is refused.
|
|
67
|
-
return under(realpathSync.native(path), realpathSync.native(root))
|
|
85
|
+
return under(realpathSync.native(path), realpathSync.native(root))
|
|
86
|
+
? { directory: path }
|
|
87
|
+
: { refused: "outside" };
|
|
68
88
|
}
|
|
69
89
|
catch {
|
|
70
|
-
return
|
|
90
|
+
return { refused: "missing" }; // it names nothing, or goes through a loop of links
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
/**
|
|
@@ -80,7 +100,26 @@ export function terminalLine(template, directory, sessionId, escapeMarkup) {
|
|
|
80
100
|
directory: shellQuote(directory),
|
|
81
101
|
session: sessionId,
|
|
82
102
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
return texts.fill(texts.STATUS_TERMINAL, { command: commandShown(command, escapeMarkup) });
|
|
104
|
+
}
|
|
105
|
+
/** The repositories `untrustedLines` names at most; the rest are counted. */
|
|
106
|
+
export const UNTRUSTED_ROWS = 10;
|
|
107
|
+
/**
|
|
108
|
+
* What tells the owner which of `roots` still need the agent's trust, each with the shell line
|
|
109
|
+
* that asks for it there (the agent's `words.trust`, `template`); empty with no root.
|
|
110
|
+
*/
|
|
111
|
+
export function untrustedLines(template, roots, names, escapeMarkup) {
|
|
112
|
+
if (roots.length === 0)
|
|
113
|
+
return [];
|
|
114
|
+
const lines = roots
|
|
115
|
+
.slice(0, UNTRUSTED_ROWS)
|
|
116
|
+
.map((root) => commandShown(texts.fill(template, { directory: shellQuote(root) }), escapeMarkup));
|
|
117
|
+
if (roots.length > UNTRUSTED_ROWS) {
|
|
118
|
+
lines.push(texts.fill(texts.UNTRUSTED_MORE, { count: roots.length - UNTRUSTED_ROWS }));
|
|
119
|
+
}
|
|
120
|
+
return [texts.named(texts.UNTRUSTED_REPOSITORIES, names), ...lines];
|
|
121
|
+
}
|
|
122
|
+
/** A shell line as a code span, which cannot hold a backtick: such a line is plain text, escaped. */
|
|
123
|
+
function commandShown(command, escapeMarkup) {
|
|
124
|
+
return command.includes("`") ? escapeMarkup(command) : `\`${command}\``;
|
|
86
125
|
}
|
|
@@ -105,7 +105,10 @@ export class SessionManager {
|
|
|
105
105
|
session = null;
|
|
106
106
|
}
|
|
107
107
|
if (session === null) {
|
|
108
|
-
const built = new ThreadSession(channelId, threadId, directory, this.deps, {
|
|
108
|
+
const built = new ThreadSession(channelId, threadId, directory, this.deps, {
|
|
109
|
+
predecessor,
|
|
110
|
+
stopped: () => this.closed,
|
|
111
|
+
});
|
|
109
112
|
built.draining = this.draining;
|
|
110
113
|
built.startsBlocked = this.startsBlocked;
|
|
111
114
|
built.onClosed = () => this.evictIfCurrent(key, built);
|
|
@@ -195,23 +198,40 @@ export class SessionManager {
|
|
|
195
198
|
* thread is held for a delete the stop would cut short.
|
|
196
199
|
*/
|
|
197
200
|
async release(channelId, threadId) {
|
|
201
|
+
if (!this.hold(channelId, threadId))
|
|
202
|
+
return false;
|
|
203
|
+
await this.closeHeld(channelId, threadId);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The first half of `release`, in one synchronous step: holds the thread unless the manager is
|
|
208
|
+
* closed, the thread is held already or its session is in use, and says whether it did. A
|
|
209
|
+
* caller that gets true owns the hold, and ends it with `free`; one that gets false took none,
|
|
210
|
+
* and so never ends another's.
|
|
211
|
+
*/
|
|
212
|
+
hold(channelId, threadId) {
|
|
198
213
|
if (this.closed)
|
|
199
214
|
return false;
|
|
200
215
|
const key = keyOf(channelId, threadId);
|
|
216
|
+
if (this.heldThreads.has(key))
|
|
217
|
+
return false;
|
|
201
218
|
const session = this.sessions.get(key);
|
|
202
|
-
|
|
203
|
-
if (inUse)
|
|
219
|
+
if (session !== undefined && !session.closed && session.inUse)
|
|
204
220
|
return false;
|
|
205
221
|
// (sync) Held before the first await: a message that arrives while the session closes, or
|
|
206
222
|
// while the thread's messages are deleted, must not build a session in it.
|
|
207
223
|
this.heldThreads.add(key);
|
|
208
|
-
if (session !== undefined) {
|
|
209
|
-
if (!session.closed)
|
|
210
|
-
await session.close(texts.ENDED_IDLE);
|
|
211
|
-
await session.doneClosing.wait();
|
|
212
|
-
}
|
|
213
224
|
return true;
|
|
214
225
|
}
|
|
226
|
+
/** The second half of `release`: closes the thread's session, if any, once `hold` took it. */
|
|
227
|
+
async closeHeld(channelId, threadId) {
|
|
228
|
+
const session = this.sessions.get(keyOf(channelId, threadId));
|
|
229
|
+
if (session === undefined)
|
|
230
|
+
return;
|
|
231
|
+
if (!session.closed)
|
|
232
|
+
await session.close(texts.ENDED_IDLE);
|
|
233
|
+
await session.doneClosing.wait();
|
|
234
|
+
}
|
|
215
235
|
/** Whether the thread is being deleted: it still has its entry, and takes no session. */
|
|
216
236
|
held(channelId, threadId) {
|
|
217
237
|
return this.heldThreads.has(keyOf(channelId, threadId));
|
|
@@ -226,15 +246,10 @@ export class SessionManager {
|
|
|
226
246
|
* same as `ThreadSession.stop`'s own null, since the held thread already got `Not sent.`.
|
|
227
247
|
*/
|
|
228
248
|
async stopChannel(channelId) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (result)
|
|
234
|
-
stopped = true;
|
|
235
|
-
else if (result === null)
|
|
236
|
-
heldOnly = true;
|
|
237
|
-
}
|
|
249
|
+
// Together: a stop that closes a process takes seconds, and the sessions share nothing.
|
|
250
|
+
const results = await Promise.all(this.sessionsOf(channelId).map((session) => session.stop()));
|
|
251
|
+
const stopped = results.some((result) => result);
|
|
252
|
+
const heldOnly = results.some((result) => result === null);
|
|
238
253
|
if (stopped)
|
|
239
254
|
return true;
|
|
240
255
|
return heldOnly ? null : false;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text quoted inside a reference, line by line. A line past `QUOTE_WIDTH` is
|
|
3
|
+
* wrapped first, after whitespace where there is some, else at a grapheme boundary, so that every
|
|
4
|
+
* piece carries its own quote mark: the chat splits a message past its limit at line breaks, and a
|
|
5
|
+
* line it had to cut itself would leave an unquoted chunk.
|
|
6
|
+
*/
|
|
7
|
+
/** The longest line of a quote, well under any chat's message limit. */
|
|
8
|
+
export const QUOTE_WIDTH = 1000;
|
|
9
|
+
// Segments lazily: a hard cut reads graphemes only up to the width, whatever the line's length.
|
|
10
|
+
const graphemes = new Intl.Segmenter();
|
|
11
|
+
/** Where a hard cut of `rest` falls: the last grapheme boundary within `width`, else the first. */
|
|
12
|
+
function graphemeCut(rest, width) {
|
|
13
|
+
let end = 0;
|
|
14
|
+
for (const { segment } of graphemes.segment(rest)) {
|
|
15
|
+
if (end > 0 && end + segment.length > width)
|
|
16
|
+
break;
|
|
17
|
+
end += segment.length;
|
|
18
|
+
}
|
|
19
|
+
return end;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `line` cut into pieces of at most `width` characters, each cut after the last whitespace of a
|
|
23
|
+
* piece, which stays at the end of it so that the pieces joined give the line back. A piece with
|
|
24
|
+
* no whitespace is cut hard at a grapheme boundary, never inside a surrogate pair, a ZWJ sequence
|
|
25
|
+
* or a combining sequence (a single grapheme longer than `width` goes whole).
|
|
26
|
+
*/
|
|
27
|
+
export function wrapped(line, width) {
|
|
28
|
+
const pieces = [];
|
|
29
|
+
let rest = line;
|
|
30
|
+
while (rest.length > width) {
|
|
31
|
+
let end = 0;
|
|
32
|
+
for (let at = width - 1; at > 0; at -= 1) {
|
|
33
|
+
if (/\s/.test(rest.charAt(at))) {
|
|
34
|
+
end = at + 1;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (end === 0)
|
|
39
|
+
end = graphemeCut(rest, width);
|
|
40
|
+
pieces.push(rest.slice(0, end));
|
|
41
|
+
rest = rest.slice(end);
|
|
42
|
+
}
|
|
43
|
+
if (rest !== "" || pieces.length === 0)
|
|
44
|
+
pieces.push(rest);
|
|
45
|
+
return pieces;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `text` with each line, and each piece of a long one, escaped by `escaped` and marked `> `. The
|
|
49
|
+
* pieces are cut before they are escaped, so an escape is never split.
|
|
50
|
+
*/
|
|
51
|
+
export function quoted(text, escaped) {
|
|
52
|
+
return text
|
|
53
|
+
.split(/\r?\n/)
|
|
54
|
+
.flatMap((line) => wrapped(line, QUOTE_WIDTH))
|
|
55
|
+
.map((piece) => `> ${escaped(piece)}`.trimEnd())
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|