@pify/swarm 0.1.0 → 0.3.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 -0
- package/extensions/swarm.ts +100 -9
- package/package.json +1 -1
- package/src/isolate.ts +81 -0
- package/src/mailbox.ts +112 -0
package/README.md
CHANGED
|
@@ -27,6 +27,15 @@ match_keywords: rust, memory safety
|
|
|
27
27
|
|
|
28
28
|
`@pify/subagent` = one child, one task. `@pify/swarm` = many independent items at once. `@pify/workflow` = deterministic scripted orchestration. Pick the smallest one that fits.
|
|
29
29
|
|
|
30
|
+
## Mailbox (v0.3)
|
|
31
|
+
|
|
32
|
+
`swarm_run(items, { mailbox: true })` gives every agent two extra tools:
|
|
33
|
+
|
|
34
|
+
- `swarm_post(message)` — tell the siblings something that changes their work: a shared file you modified, a convention you had to pick, a blocker they will hit too.
|
|
35
|
+
- `swarm_inbox()` — read what the others posted since your last check.
|
|
36
|
+
|
|
37
|
+
Without it, parallel agents cannot see each other, so two of them cheerfully fix the same shared helper in two different ways. It is deliberately not a chat: no addressing, no waiting, no replies — an append-only log per run, and an agent never sees its own posts echoed back. A torn line from two simultaneous appends is skipped rather than failing the read.
|
|
38
|
+
|
|
30
39
|
## License
|
|
31
40
|
|
|
32
41
|
MIT © [Pify maintainers](https://github.com/pifydev)
|
package/extensions/swarm.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getAgentDir,
|
|
18
18
|
SessionManager,
|
|
19
19
|
type AgentSession,
|
|
20
|
+
type ToolDefinition,
|
|
20
21
|
type ExtensionAPI,
|
|
21
22
|
type ExtensionContext,
|
|
22
23
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -24,6 +25,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
24
25
|
import { Type } from "typebox";
|
|
25
26
|
|
|
26
27
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
28
|
+
import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
|
|
29
|
+
import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
|
|
27
30
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
28
31
|
import { buildReport, buildStatusLine } from "../src/report.ts";
|
|
29
32
|
import { routeItem } from "../src/routing.ts";
|
|
@@ -99,7 +102,59 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
99
102
|
|
|
100
103
|
// ── Child runner (subagent-proven pattern, one per item) ─────────────
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Mailbox tools for one child. Each agent posts under its own label and
|
|
107
|
+
* never sees its own posts echoed back; `seen` advances per agent so a
|
|
108
|
+
* second swarm_inbox only reports what arrived since the first.
|
|
109
|
+
*/
|
|
110
|
+
function mailboxTools(dir: string, label: string): ToolDefinition[] {
|
|
111
|
+
let seen = 0;
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
name: "swarm_post",
|
|
115
|
+
label: "Post to swarm",
|
|
116
|
+
description:
|
|
117
|
+
"Tell the other agents in this swarm something that changes their work: a shared file you " +
|
|
118
|
+
"modified, a convention you had to choose, a blocker they will hit too. Not for progress " +
|
|
119
|
+
"narration — only facts a sibling needs to avoid redoing or undoing your work.",
|
|
120
|
+
parameters: Type.Object({
|
|
121
|
+
message: Type.String({ description: "One fact the other agents need" }),
|
|
122
|
+
}),
|
|
123
|
+
async execute(_id: string, params: { message: string }) {
|
|
124
|
+
const posted = postMessage(dir, label, params.message, Date.now());
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: `Posted #${posted.seq} to the swarm.` }],
|
|
127
|
+
details: { seq: posted.seq },
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "swarm_inbox",
|
|
133
|
+
label: "Read swarm inbox",
|
|
134
|
+
description:
|
|
135
|
+
"Read what the other agents in this swarm have posted since you last checked. Call it " +
|
|
136
|
+
"before you start working and again before you finish.",
|
|
137
|
+
parameters: Type.Object({}),
|
|
138
|
+
async execute() {
|
|
139
|
+
const read = readInbox(dir, label, seen);
|
|
140
|
+
seen = read.nextSeq;
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: formatInbox(read) }],
|
|
143
|
+
details: { count: read.messages.length },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
] as unknown as ToolDefinition[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function runItem(
|
|
151
|
+
ctx: UiContext,
|
|
152
|
+
def: AgentDef,
|
|
153
|
+
item: ItemState,
|
|
154
|
+
context: string,
|
|
155
|
+
workDir?: string,
|
|
156
|
+
mailbox?: string,
|
|
157
|
+
): Promise<void> {
|
|
103
158
|
item.status = "running";
|
|
104
159
|
renderWidget();
|
|
105
160
|
let session: AgentSession | null = null;
|
|
@@ -120,12 +175,13 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
120
175
|
const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
|
|
121
176
|
|
|
122
177
|
const created = await createAgentSession({
|
|
123
|
-
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
178
|
+
sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
|
|
124
179
|
model,
|
|
125
180
|
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
126
181
|
tools: def.tools,
|
|
182
|
+
...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
|
|
127
183
|
resourceLoader: new DefaultResourceLoader({
|
|
128
|
-
cwd: ctx.cwd,
|
|
184
|
+
cwd: workDir ?? ctx.cwd,
|
|
129
185
|
agentDir: getAgentDir(),
|
|
130
186
|
noExtensions: true,
|
|
131
187
|
noPromptTemplates: true,
|
|
@@ -135,6 +191,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
135
191
|
...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
|
|
136
192
|
def.systemPrompt,
|
|
137
193
|
"You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
|
|
194
|
+
...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
|
|
138
195
|
],
|
|
139
196
|
}),
|
|
140
197
|
});
|
|
@@ -192,7 +249,16 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
192
249
|
}
|
|
193
250
|
|
|
194
251
|
/** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
|
|
195
|
-
async function executeRun(
|
|
252
|
+
async function executeRun(
|
|
253
|
+
ctx: UiContext,
|
|
254
|
+
run: SwarmRun,
|
|
255
|
+
context: string,
|
|
256
|
+
fixed?: string,
|
|
257
|
+
isolate?: boolean,
|
|
258
|
+
useMailbox?: boolean,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
// One shared log per run; only created when the caller asked for it.
|
|
261
|
+
const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
|
|
196
262
|
const queue = [...run.items];
|
|
197
263
|
const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
|
|
198
264
|
for (;;) {
|
|
@@ -200,7 +266,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
200
266
|
if (!item) return;
|
|
201
267
|
const def = routeItem(item.item, defs, fixed);
|
|
202
268
|
item.agent = def.name;
|
|
203
|
-
|
|
269
|
+
if (isolate) {
|
|
270
|
+
try {
|
|
271
|
+
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
272
|
+
await runItem(ctx, def, item, context, iso.path, mailbox);
|
|
273
|
+
if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
item.status = "error";
|
|
276
|
+
item.error = err instanceof Error ? err.message : String(err);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
await runItem(ctx, def, item, context, undefined, mailbox);
|
|
280
|
+
}
|
|
204
281
|
}
|
|
205
282
|
});
|
|
206
283
|
await Promise.all(workers);
|
|
@@ -220,16 +297,30 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
220
297
|
"Each item auto-routes to an agent type via its match_patterns/match_keywords, falling back to the " +
|
|
221
298
|
"read-only scout; set agent to force one type for all items. context is prepended to every item. " +
|
|
222
299
|
"Blocking by default (returns the aggregated report); background=true returns a runId for swarm_status. " +
|
|
223
|
-
"Write each item as a self-contained brief — children see nothing else."
|
|
300
|
+
"Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
|
|
301
|
+
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
|
|
302
|
+
"mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
|
|
303
|
+
"conventions instead of silently conflicting.",
|
|
224
304
|
parameters: Type.Object({
|
|
225
305
|
items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
|
|
226
306
|
context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
|
|
227
307
|
agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
|
|
308
|
+
isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
|
|
309
|
+
mailbox: Type.Optional(
|
|
310
|
+
Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
|
|
311
|
+
),
|
|
228
312
|
background: Type.Optional(Type.Boolean()),
|
|
229
313
|
}),
|
|
230
314
|
async execute(
|
|
231
315
|
_id,
|
|
232
|
-
params: {
|
|
316
|
+
params: {
|
|
317
|
+
items: string[];
|
|
318
|
+
context?: string;
|
|
319
|
+
agent?: string;
|
|
320
|
+
background?: boolean;
|
|
321
|
+
isolation?: string;
|
|
322
|
+
mailbox?: boolean;
|
|
323
|
+
},
|
|
233
324
|
_signal,
|
|
234
325
|
_onUpdate,
|
|
235
326
|
ctx,
|
|
@@ -267,7 +358,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
267
358
|
renderWidget(uiCtx);
|
|
268
359
|
|
|
269
360
|
if (run.background) {
|
|
270
|
-
void executeRun(uiCtx, run, params.context ?? "", params.agent).then(() => {
|
|
361
|
+
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
|
|
271
362
|
notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
|
|
272
363
|
});
|
|
273
364
|
return {
|
|
@@ -278,7 +369,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
278
369
|
};
|
|
279
370
|
}
|
|
280
371
|
|
|
281
|
-
await executeRun(uiCtx, run, params.context ?? "", params.agent);
|
|
372
|
+
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
|
|
282
373
|
return {
|
|
283
374
|
content: [{ type: "text", text: buildReport(run) }],
|
|
284
375
|
details: { runId: run.runId },
|
package/package.json
CHANGED
package/src/isolate.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree isolation for child agents (v0.2 integration with the suite's
|
|
8
|
+
* worktree conventions): a mutating child gets its own git worktree on an
|
|
9
|
+
* agent/<slug> branch under ~/.worktrees/<repo>/, so parallel edits can
|
|
10
|
+
* never collide with the main checkout. All git calls are execFile argv —
|
|
11
|
+
* no shell, no interpolation. The worktree is NOT auto-removed: the result
|
|
12
|
+
* reports it so the user merges (worktree_merge from @pify/worktree, or
|
|
13
|
+
* plain git) or discards deliberately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface Isolation {
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(cwd: string, args: string[]): string {
|
|
22
|
+
return execFileSync("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: 30_000,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sanitizeSlug(raw: string): string {
|
|
32
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
33
|
+
return slug || "run";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
|
|
37
|
+
let toplevel: string;
|
|
38
|
+
try {
|
|
39
|
+
toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error("Worktree isolation requires a git repository.");
|
|
42
|
+
}
|
|
43
|
+
const repo = basename(toplevel);
|
|
44
|
+
const slug = sanitizeSlug(rawSlug);
|
|
45
|
+
|
|
46
|
+
let branch = `agent/${slug}`;
|
|
47
|
+
let path = join(homedir(), ".worktrees", repo, slug);
|
|
48
|
+
let counter = 2;
|
|
49
|
+
while (existsSync(path) || branchExists(cwd, branch)) {
|
|
50
|
+
branch = `agent/${slug}-${counter}`;
|
|
51
|
+
path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
|
|
52
|
+
counter++;
|
|
53
|
+
if (counter > 50) throw new Error("Could not find a free worktree slot.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
git(cwd, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const e = err as { stderr?: string; message?: string };
|
|
60
|
+
throw new Error(`git worktree add failed: ${(e.stderr ?? e.message ?? "unknown").toString().trim()}`);
|
|
61
|
+
}
|
|
62
|
+
return { path, branch };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function branchExists(cwd: string, branch: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Note appended to a child's report when it ran isolated. */
|
|
75
|
+
export function isolationNote(isolation: Isolation): string {
|
|
76
|
+
return [
|
|
77
|
+
`Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
|
|
78
|
+
`The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
|
|
79
|
+
`or inspect: cd "${isolation.path}" && git log --stat`,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
package/src/mailbox.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run mailbox (v0.3, gjczone's shared inbox/outbox). Swarm agents work the
|
|
3
|
+
* same repository at the same time and cannot see each other, so two of them
|
|
4
|
+
* happily fix the same shared helper in two different ways. A mailbox is the
|
|
5
|
+
* cheapest fix: one append-only log per run, readable by every sibling.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a chat. Agents post facts they discovered that change
|
|
8
|
+
* someone else's work, and read what others posted; there is no addressing,
|
|
9
|
+
* no waiting, and no reply. Anything richer needs steering, which is a
|
|
10
|
+
* different feature.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface MailMessage {
|
|
17
|
+
seq: number;
|
|
18
|
+
from: string;
|
|
19
|
+
text: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MAX_MESSAGE_CHARS = 1200;
|
|
24
|
+
export const MAX_INBOX_MESSAGES = 30;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One directory per run under the agent dir. Run ids are generated locally,
|
|
28
|
+
* but this builds a filesystem path, so it stays a single flat segment: no
|
|
29
|
+
* separators and no `..` can survive the sanitizer.
|
|
30
|
+
*/
|
|
31
|
+
export function mailboxDir(agentDir: string, runId: string): string {
|
|
32
|
+
const safe =
|
|
33
|
+
runId
|
|
34
|
+
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
35
|
+
.replace(/\.{2,}/g, "-")
|
|
36
|
+
.replace(/^[.-]+/, "")
|
|
37
|
+
.slice(0, 80) || "run";
|
|
38
|
+
return join(agentDir, "swarm-mailbox", safe);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function logPath(dir: string): string {
|
|
42
|
+
return join(dir, "messages.jsonl");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readMailbox(dir: string): MailMessage[] {
|
|
46
|
+
let raw: string;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(logPath(dir), "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const messages: MailMessage[] = [];
|
|
53
|
+
for (const line of raw.split("\n")) {
|
|
54
|
+
if (!line.trim()) continue;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(line) as MailMessage;
|
|
57
|
+
if (typeof parsed.seq === "number" && typeof parsed.from === "string" && typeof parsed.text === "string") {
|
|
58
|
+
messages.push(parsed);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// a torn line from a concurrent append — skip it, never fail the read
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return messages;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Append one message. Concurrency is handled by the filesystem: a single
|
|
69
|
+
* append of one line is atomic enough for this, and a torn read is skipped
|
|
70
|
+
* rather than treated as an error.
|
|
71
|
+
*/
|
|
72
|
+
export function postMessage(dir: string, from: string, text: string, now: number): MailMessage {
|
|
73
|
+
const trimmed = text.trim().slice(0, MAX_MESSAGE_CHARS);
|
|
74
|
+
if (!trimmed) throw new Error("A mailbox message cannot be empty.");
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
const seq = readMailbox(dir).length + 1;
|
|
77
|
+
const message: MailMessage = { seq, from, text: trimmed, timestamp: now };
|
|
78
|
+
appendFileSync(logPath(dir), `${JSON.stringify(message)}\n`);
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface InboxRead {
|
|
83
|
+
messages: MailMessage[];
|
|
84
|
+
/** Pass back as `sinceSeq` to read only what arrives after this. */
|
|
85
|
+
nextSeq: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Messages from OTHER agents after `sinceSeq`. Own posts are never echoed. */
|
|
89
|
+
export function readInbox(dir: string, reader: string, sinceSeq = 0): InboxRead {
|
|
90
|
+
const all = readMailbox(dir);
|
|
91
|
+
const highest = all.reduce((max, m) => Math.max(max, m.seq), 0);
|
|
92
|
+
const messages = all
|
|
93
|
+
.filter((m) => m.seq > sinceSeq && m.from !== reader)
|
|
94
|
+
.slice(-MAX_INBOX_MESSAGES);
|
|
95
|
+
return { messages, nextSeq: highest };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatInbox(read: InboxRead): string {
|
|
99
|
+
if (read.messages.length === 0) return "No new messages from the other agents.";
|
|
100
|
+
return read.messages.map((m) => `[#${m.seq} from ${m.from}] ${m.text}`).join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The instruction children get, naming their own label so posts are attributable. */
|
|
104
|
+
export function mailboxPrompt(label: string): string {
|
|
105
|
+
return [
|
|
106
|
+
`You are agent "${label}" in a parallel swarm working the same repository.`,
|
|
107
|
+
"Use swarm_post to tell the other agents something that changes their work:",
|
|
108
|
+
"a shared file you modified, a convention you had to pick, a blocker they will hit too.",
|
|
109
|
+
"Use swarm_inbox before you start and again before you finish, so you do not redo",
|
|
110
|
+
"or undo someone else's work. Do not post progress narration — only facts others need.",
|
|
111
|
+
].join(" ");
|
|
112
|
+
}
|