xyne-plugin-spaces 0.1.0 → 0.4.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/bun.lock +3 -2
- package/package.json +2 -2
- package/src/index.ts +181 -4
- package/src/query.ts +91 -2
package/bun.lock
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 0,
|
|
3
4
|
"workspaces": {
|
|
4
5
|
"": {
|
|
5
6
|
"name": "xyne-spaces",
|
|
6
7
|
"dependencies": {
|
|
7
|
-
"xyne-plugin": "^1.2.
|
|
8
|
+
"xyne-plugin": "^1.2.10",
|
|
8
9
|
},
|
|
9
10
|
"devDependencies": {
|
|
10
11
|
"typescript": "^5.9.3",
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
"packages": {
|
|
15
16
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
16
17
|
|
|
17
|
-
"xyne-plugin": ["xyne-plugin@1.2.
|
|
18
|
+
"xyne-plugin": ["xyne-plugin@1.2.14", "", { "dependencies": { "zod": "^3.25.0" } }, "sha512-A5oPZDspJUfDGpXkFrAOuiDVqJziRpJDteddJHysG6yYCot8gVe5eaZnPKrxnARliHl7smBfHEI5WTGUOlPTXg=="],
|
|
18
19
|
|
|
19
20
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
20
21
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xyne-plugin-spaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"typecheck": "tsc --noEmit"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"xyne-plugin": "^1.2.
|
|
10
|
+
"xyne-plugin": "^1.2.17"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"typescript": "^5.9.3"
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Plugin, tool, z } from "xyne-plugin"
|
|
2
2
|
import { spacesAuth, spacesFetch } from "./auth"
|
|
3
|
-
import { queryTickets, queryMessages, queryMessageDetail, queryChannels, queryUsers, queryUserActivity } from "./query"
|
|
3
|
+
import { queryTickets, queryMessages, queryMessageDetail, queryChannels, queryUsers, queryUserActivity, queryProjects, queryBoards } from "./query"
|
|
4
4
|
|
|
5
5
|
function getToken(ctx: { credentials?: { type: string; data?: Record<string, unknown> } }): string {
|
|
6
6
|
const creds = ctx.credentials
|
|
@@ -212,17 +212,19 @@ const spacesMessageDetail = tool({
|
|
|
212
212
|
|
|
213
213
|
const spacesChannels = tool({
|
|
214
214
|
description:
|
|
215
|
-
"List channels in Spaces. Can filter by visibility (PUBLIC/PRIVATE)
|
|
216
|
-
"Returns channel name,
|
|
215
|
+
"List channels in Spaces. Can filter by visibility (PUBLIC/PRIVATE), scope type (DEFAULT/DM/TICKET/GROUP_DM), " +
|
|
216
|
+
"and participant name. Returns channel name, members, conversation ID, and last activity. " +
|
|
217
|
+
"To find a DM between two people, use scopeType='DM' and participantName to filter by one of them.",
|
|
217
218
|
auth: spacesAuth,
|
|
218
219
|
args: {
|
|
219
220
|
visibility: z.enum(["PUBLIC", "PRIVATE"]).optional().describe("Filter by visibility"),
|
|
220
221
|
scopeType: z.enum(["DEFAULT", "DM", "TICKET", "DOCUMENT", "GROUP_DM"]).optional().describe("Filter by scope type"),
|
|
222
|
+
participantName: z.string().optional().describe("Filter channels by participant name (partial match)"),
|
|
221
223
|
limit: z.number().min(1).max(50).default(20).describe("Max channels (default 20)"),
|
|
222
224
|
},
|
|
223
225
|
async execute(args, ctx) {
|
|
224
226
|
const token = getToken(ctx)
|
|
225
|
-
return queryChannels(token, args.limit, args.visibility, args.scopeType)
|
|
227
|
+
return queryChannels(token, args.limit, args.visibility, args.scopeType, args.participantName)
|
|
226
228
|
},
|
|
227
229
|
})
|
|
228
230
|
|
|
@@ -317,6 +319,155 @@ const spacesWebFetch = tool({
|
|
|
317
319
|
},
|
|
318
320
|
})
|
|
319
321
|
|
|
322
|
+
// ── Projects ────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
const spacesProjects = tool({
|
|
325
|
+
description:
|
|
326
|
+
"Search and list projects to find project IDs for creating tickets. " +
|
|
327
|
+
"Can filter by name with pagination support.",
|
|
328
|
+
auth: spacesAuth,
|
|
329
|
+
args: {
|
|
330
|
+
search: z.string().optional().describe("Filter by project name (partial match)"),
|
|
331
|
+
limit: z.number().min(1).max(50).default(20).describe("Max results (default 20)"),
|
|
332
|
+
offset: z.number().min(0).default(0).describe("Pagination offset"),
|
|
333
|
+
},
|
|
334
|
+
async execute(args, ctx) {
|
|
335
|
+
const token = getToken(ctx)
|
|
336
|
+
return queryProjects(token, args.search, args.limit, args.offset)
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// ── Boards ──────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const spacesBoards = tool({
|
|
343
|
+
description:
|
|
344
|
+
"Search and list boards to find board IDs for creating tickets. " +
|
|
345
|
+
"Can filter by name or project ID with pagination support.",
|
|
346
|
+
auth: spacesAuth,
|
|
347
|
+
args: {
|
|
348
|
+
search: z.string().optional().describe("Filter by board name (partial match)"),
|
|
349
|
+
projectId: z.string().optional().describe("Filter by project ID (use spaces-projects to find project IDs)"),
|
|
350
|
+
limit: z.number().min(1).max(50).default(20).describe("Max results (default 20)"),
|
|
351
|
+
offset: z.number().min(0).default(0).describe("Pagination offset"),
|
|
352
|
+
},
|
|
353
|
+
async execute(args, ctx) {
|
|
354
|
+
const token = getToken(ctx)
|
|
355
|
+
return queryBoards(token, args.search, args.projectId, args.limit, args.offset)
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// ── Send Message ────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
const spacesSendMessage = tool({
|
|
362
|
+
description:
|
|
363
|
+
"Send a message in Spaces by replying to an existing conversation thread. " +
|
|
364
|
+
"Use the conversationId from spaces-tickets or spaces-messages results (NOT channelId or ticketId).",
|
|
365
|
+
auth: spacesAuth,
|
|
366
|
+
args: {
|
|
367
|
+
conversationId: z.string().describe("The conversationId to send the message to (from spaces-tickets or spaces-messages results)"),
|
|
368
|
+
content: z.string().describe("Message content to send"),
|
|
369
|
+
},
|
|
370
|
+
async execute(args, ctx) {
|
|
371
|
+
const token = getToken(ctx)
|
|
372
|
+
const data = (await spacesFetch(`/chat/postMessage/${args.conversationId}`, token, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
body: JSON.stringify({ content: args.content }),
|
|
375
|
+
})) as { messageId: string; conversationId: string; content: string; sender?: { name: string } }
|
|
376
|
+
|
|
377
|
+
return `Message sent. messageId: ${data.messageId}, conversationId: ${data.conversationId}`
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// ── Create Ticket ───────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const spacesCreateTicket = tool({
|
|
384
|
+
description:
|
|
385
|
+
"Create a new ticket in Spaces. Requires projectId, boardId, and channelId — " +
|
|
386
|
+
"use spaces-projects, spaces-boards, and spaces-channels to look these up first.",
|
|
387
|
+
auth: spacesAuth,
|
|
388
|
+
args: {
|
|
389
|
+
title: z.string().describe("Ticket title"),
|
|
390
|
+
description: z.string().describe("Ticket description"),
|
|
391
|
+
projectId: z.string().describe("Project ID (use spaces-projects to find)"),
|
|
392
|
+
boardId: z.string().describe("Board ID (use spaces-boards to find)"),
|
|
393
|
+
channelId: z.string().describe("Channel ID (use spaces-channels to find)"),
|
|
394
|
+
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional().describe("Ticket priority"),
|
|
395
|
+
assignedTo: z.string().optional().describe("User ID to assign (use spaces-users to find)"),
|
|
396
|
+
eta: z.string().optional().describe("Due date as ISO 8601 string"),
|
|
397
|
+
tags: z.array(z.string()).optional().describe("Tags to apply"),
|
|
398
|
+
},
|
|
399
|
+
async execute(args, ctx) {
|
|
400
|
+
const token = getToken(ctx)
|
|
401
|
+
const body: Record<string, unknown> = {
|
|
402
|
+
title: args.title,
|
|
403
|
+
description: args.description,
|
|
404
|
+
projectId: args.projectId,
|
|
405
|
+
boardId: args.boardId,
|
|
406
|
+
channelId: args.channelId,
|
|
407
|
+
}
|
|
408
|
+
if (args.priority) body["priority"] = args.priority
|
|
409
|
+
if (args.assignedTo) body["assignedTo"] = args.assignedTo
|
|
410
|
+
if (args.eta) body["eta"] = args.eta
|
|
411
|
+
if (args.tags) body["tags"] = args.tags
|
|
412
|
+
|
|
413
|
+
const data = (await spacesFetch("/ticket/create", token, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
body: JSON.stringify(body),
|
|
416
|
+
})) as { id: string; xyneId: string; conversationId: string; title: string; priority: string; status: string }
|
|
417
|
+
|
|
418
|
+
return [
|
|
419
|
+
`Ticket created:`,
|
|
420
|
+
` xyneId: ${data.xyneId}`,
|
|
421
|
+
` ID: ${data.id}`,
|
|
422
|
+
` Status: ${data.status}`,
|
|
423
|
+
` Priority: ${data.priority}`,
|
|
424
|
+
` ConversationID: ${data.conversationId}`,
|
|
425
|
+
].join("\n")
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// ── Schedule Call ───────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
const spacesScheduleCall = tool({
|
|
432
|
+
description:
|
|
433
|
+
"Schedule a call in Spaces. Must provide either a channelId or targetUserIds (list of user IDs to invite).",
|
|
434
|
+
auth: spacesAuth,
|
|
435
|
+
args: {
|
|
436
|
+
title: z.string().describe("Call title"),
|
|
437
|
+
startsAt: z.string().describe("Start time as ISO 8601 string (e.g. '2026-03-28T10:00:00Z')"),
|
|
438
|
+
endsAt: z.string().describe("End time as ISO 8601 string"),
|
|
439
|
+
channelId: z.string().optional().describe("Channel ID to schedule the call in"),
|
|
440
|
+
targetUserIds: z.array(z.string()).optional().describe("User IDs to invite (use spaces-users to find)"),
|
|
441
|
+
},
|
|
442
|
+
async execute(args, ctx) {
|
|
443
|
+
if (!args.channelId && (!args.targetUserIds || args.targetUserIds.length === 0)) {
|
|
444
|
+
throw new Error("Must provide either channelId or targetUserIds")
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const token = getToken(ctx)
|
|
448
|
+
const body: Record<string, unknown> = {
|
|
449
|
+
title: args.title,
|
|
450
|
+
startsAt: new Date(args.startsAt).getTime(),
|
|
451
|
+
endsAt: new Date(args.endsAt).getTime(),
|
|
452
|
+
}
|
|
453
|
+
if (args.channelId) body["channelId"] = args.channelId
|
|
454
|
+
if (args.targetUserIds) body["targetUserIds"] = args.targetUserIds
|
|
455
|
+
|
|
456
|
+
const data = (await spacesFetch("/calls/schedule", token, {
|
|
457
|
+
method: "POST",
|
|
458
|
+
body: JSON.stringify(body),
|
|
459
|
+
})) as { success: boolean; callId?: string; externalId?: string; channelId?: string }
|
|
460
|
+
|
|
461
|
+
if (!data.success) return "Failed to schedule call."
|
|
462
|
+
return [
|
|
463
|
+
`Call scheduled:`,
|
|
464
|
+
` callId: ${data.callId}`,
|
|
465
|
+
` externalId: ${data.externalId}`,
|
|
466
|
+
` channelId: ${data.channelId}`,
|
|
467
|
+
].join("\n")
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
|
|
320
471
|
// ── Plugin ───────────────────────────────────────────────────────────
|
|
321
472
|
|
|
322
473
|
const spacesTools = {
|
|
@@ -328,10 +479,31 @@ const spacesTools = {
|
|
|
328
479
|
"spaces-channels": spacesChannels,
|
|
329
480
|
"spaces-users": spacesUsers,
|
|
330
481
|
"spaces-activity": spacesUserActivity,
|
|
482
|
+
"spaces-projects": spacesProjects,
|
|
483
|
+
"spaces-boards": spacesBoards,
|
|
484
|
+
"spaces-send-message": spacesSendMessage,
|
|
485
|
+
"spaces-create-ticket": spacesCreateTicket,
|
|
486
|
+
"spaces-schedule-call": spacesScheduleCall,
|
|
331
487
|
"webfetch": spacesWebFetch,
|
|
332
488
|
}
|
|
333
489
|
|
|
334
490
|
const plugin: Plugin = async (_input) => ({
|
|
491
|
+
"chat.system.transform": async (
|
|
492
|
+
_input: { sessionID: string; model: unknown },
|
|
493
|
+
output: { system: string[] },
|
|
494
|
+
) => {
|
|
495
|
+
output.system.push([
|
|
496
|
+
"",
|
|
497
|
+
"## Xyne Spaces",
|
|
498
|
+
"You have access to a 'spaces' agent (via the Task tool) that can search the team's Xyne Spaces workspace.",
|
|
499
|
+
"PROACTIVELY delegate to the spaces agent to gather context before making decisions:",
|
|
500
|
+
"- Before architectural decisions, ask spaces agent to search memory for past decisions on the topic",
|
|
501
|
+
"- Before implementing a feature, ask spaces agent for SOPs or patterns the team follows",
|
|
502
|
+
"- When debugging, ask spaces agent for related tickets or known issues",
|
|
503
|
+
"- When you need to understand a ticket or conversation, delegate to the spaces agent",
|
|
504
|
+
"Example: Use the Task tool with subagent_type='spaces' and a prompt like 'Search memory for decisions about authentication patterns'",
|
|
505
|
+
].join("\n"))
|
|
506
|
+
},
|
|
335
507
|
agent: {
|
|
336
508
|
spaces: {
|
|
337
509
|
description:
|
|
@@ -352,6 +524,11 @@ const plugin: Plugin = async (_input) => ({
|
|
|
352
524
|
"- **Channels**: Browse channels by visibility and scope",
|
|
353
525
|
"- **Users**: Look up users by name or email",
|
|
354
526
|
"- **Activity**: View user activity feed (mentions, replies, assignments)",
|
|
527
|
+
"- **Projects**: Search/list projects (needed for ticket creation)",
|
|
528
|
+
"- **Boards**: Search/list boards by name or project (needed for ticket creation)",
|
|
529
|
+
"- **Send Message**: Reply to an existing conversation thread",
|
|
530
|
+
"- **Create Ticket**: Create a new ticket with title, description, project, board, channel",
|
|
531
|
+
"- **Schedule Call**: Schedule a call in a channel or with specific users",
|
|
355
532
|
"",
|
|
356
533
|
"## Guidelines",
|
|
357
534
|
"- Use spaces-search for broad queries across all connected apps",
|
package/src/query.ts
CHANGED
|
@@ -221,14 +221,19 @@ interface ChannelRow {
|
|
|
221
221
|
scopeType: string
|
|
222
222
|
visibility: string
|
|
223
223
|
participantCount: number
|
|
224
|
+
conversationId?: string
|
|
224
225
|
lastActivityAt?: string
|
|
225
226
|
project?: { name: string } | null
|
|
227
|
+
participants?: Array<{ user?: { name: string } | null }> | null
|
|
226
228
|
}
|
|
227
229
|
|
|
228
|
-
export async function queryChannels(token: string, limit: number, visibility?: string, scopeType?: string): Promise<string> {
|
|
230
|
+
export async function queryChannels(token: string, limit: number, visibility?: string, scopeType?: string, participantName?: string): Promise<string> {
|
|
229
231
|
const where: Record<string, unknown> = {}
|
|
230
232
|
if (visibility) where["visibility"] = { equals: visibility }
|
|
231
233
|
if (scopeType) where["scopeType"] = { equals: scopeType }
|
|
234
|
+
if (participantName) {
|
|
235
|
+
where["participants"] = { some: { user: { name: { contains: participantName } } } }
|
|
236
|
+
}
|
|
232
237
|
|
|
233
238
|
const rows = (await query(token, {
|
|
234
239
|
model: "channel",
|
|
@@ -238,6 +243,7 @@ export async function queryChannels(token: string, limit: number, visibility?: s
|
|
|
238
243
|
take: limit,
|
|
239
244
|
include: {
|
|
240
245
|
project: { select: { name: true } },
|
|
246
|
+
participants: { select: { user: { select: { name: true } } } },
|
|
241
247
|
},
|
|
242
248
|
})) as ChannelRow[]
|
|
243
249
|
|
|
@@ -246,8 +252,12 @@ export async function queryChannels(token: string, limit: number, visibility?: s
|
|
|
246
252
|
const lines = rows.map((c) => {
|
|
247
253
|
const parts = [`#${c.name} (${c.scopeType}, ${c.visibility})`]
|
|
248
254
|
if (c.description) parts.push(` ${c.description}`)
|
|
249
|
-
|
|
255
|
+
const memberNames = c.participants?.map((p) => p.user?.name).filter(Boolean) ?? []
|
|
256
|
+
if (memberNames.length > 0) parts.push(` Members: ${memberNames.join(", ")}`)
|
|
257
|
+
else parts.push(` Participants: ${c.participantCount}`)
|
|
258
|
+
if (c.project) parts.push(` Project: ${c.project.name}`)
|
|
250
259
|
if (c.lastActivityAt) parts.push(` Last active: ${new Date(c.lastActivityAt).toLocaleString()}`)
|
|
260
|
+
if (c.conversationId) parts.push(` ConversationID: ${c.conversationId}`)
|
|
251
261
|
parts.push(` ID: ${c.id}`)
|
|
252
262
|
return parts.join("\n")
|
|
253
263
|
})
|
|
@@ -336,3 +346,82 @@ export async function queryUserActivity(
|
|
|
336
346
|
|
|
337
347
|
return `${rows.length} activity entries:\n\n${lines.join("\n")}`
|
|
338
348
|
}
|
|
349
|
+
|
|
350
|
+
// ── Projects ────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
interface ProjectRow {
|
|
353
|
+
id: string
|
|
354
|
+
name: string
|
|
355
|
+
description?: string
|
|
356
|
+
createdAt?: string
|
|
357
|
+
updatedAt?: string
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function queryProjects(token: string, search?: string, limit?: number, offset?: number): Promise<string> {
|
|
361
|
+
const where: Record<string, unknown> = {}
|
|
362
|
+
if (search) where["name"] = { contains: search }
|
|
363
|
+
|
|
364
|
+
const rows = (await query(token, {
|
|
365
|
+
model: "project",
|
|
366
|
+
operation: "findMany",
|
|
367
|
+
where,
|
|
368
|
+
orderBy: [{ updatedAt: "desc" }],
|
|
369
|
+
take: limit ?? 20,
|
|
370
|
+
skip: offset ?? 0,
|
|
371
|
+
})) as ProjectRow[]
|
|
372
|
+
|
|
373
|
+
if (rows.length === 0) return search ? `No projects found matching "${search}".` : "No projects found."
|
|
374
|
+
|
|
375
|
+
const lines = rows.map((p) => {
|
|
376
|
+
const parts = [p.name]
|
|
377
|
+
if (p.description) parts.push(` ${p.description}`)
|
|
378
|
+
parts.push(` ID: ${p.id}`)
|
|
379
|
+
if (p.updatedAt) parts.push(` Updated: ${new Date(p.updatedAt).toLocaleString()}`)
|
|
380
|
+
return parts.join("\n")
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
return `${rows.length} project(s):\n\n${lines.join("\n\n")}`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Boards ──────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
interface BoardRow {
|
|
389
|
+
id: string
|
|
390
|
+
name: string
|
|
391
|
+
description?: string
|
|
392
|
+
projectId?: string
|
|
393
|
+
project?: { name: string } | null
|
|
394
|
+
createdAt?: string
|
|
395
|
+
updatedAt?: string
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function queryBoards(token: string, search?: string, projectId?: string, limit?: number, offset?: number): Promise<string> {
|
|
399
|
+
const where: Record<string, unknown> = {}
|
|
400
|
+
if (search) where["name"] = { contains: search }
|
|
401
|
+
if (projectId) where["projectId"] = { equals: projectId }
|
|
402
|
+
|
|
403
|
+
const rows = (await query(token, {
|
|
404
|
+
model: "board",
|
|
405
|
+
operation: "findMany",
|
|
406
|
+
where,
|
|
407
|
+
orderBy: [{ updatedAt: "desc" }],
|
|
408
|
+
take: limit ?? 20,
|
|
409
|
+
skip: offset ?? 0,
|
|
410
|
+
include: {
|
|
411
|
+
project: { select: { name: true } },
|
|
412
|
+
},
|
|
413
|
+
})) as BoardRow[]
|
|
414
|
+
|
|
415
|
+
if (rows.length === 0) return search ? `No boards found matching "${search}".` : "No boards found."
|
|
416
|
+
|
|
417
|
+
const lines = rows.map((b) => {
|
|
418
|
+
const parts = [b.name]
|
|
419
|
+
if (b.description) parts.push(` ${b.description}`)
|
|
420
|
+
if (b.project) parts.push(` Project: ${b.project.name}`)
|
|
421
|
+
parts.push(` ID: ${b.id}`)
|
|
422
|
+
if (b.updatedAt) parts.push(` Updated: ${new Date(b.updatedAt).toLocaleString()}`)
|
|
423
|
+
return parts.join("\n")
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
return `${rows.length} board(s):\n\n${lines.join("\n\n")}`
|
|
427
|
+
}
|