companionbot 0.14.0 → 0.15.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/dist/agents/manager.js +69 -11
- package/dist/telegram/bot.js +3 -1
- package/dist/telegram/handlers/commands.js +32 -6
- package/dist/telegram/utils/prompt.js +3 -43
- package/dist/tools/agent.js +53 -0
- package/dist/tools/file.js +63 -0
- package/dist/tools/index.js +99 -961
- package/dist/tools/memory.js +54 -0
- package/dist/tools/model.js +21 -0
- package/dist/tools/schedule.js +343 -0
- package/dist/tools/session.js +227 -0
- package/dist/tools/utils.js +39 -0
- package/dist/tools/weather.js +39 -0
- package/dist/tools/web.js +103 -0
- package/package.json +1 -1
package/dist/agents/manager.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import Anthropic from "@anthropic-ai/sdk";
|
|
10
10
|
import { randomUUID } from "crypto";
|
|
11
|
+
// ===== 제한 상수 =====
|
|
12
|
+
const MAX_CONCURRENT_AGENTS = 10; // 전체 동시 Agent 최대 개수
|
|
13
|
+
const MAX_AGENTS_PER_CHAT = 3; // chatId당 최대 동시 Agent 개수
|
|
14
|
+
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000; // 30분마다 cleanup
|
|
15
|
+
const AGENT_TTL_MS = 30 * 60 * 1000; // Agent 보관 시간 (30분)
|
|
11
16
|
// Agent 저장소
|
|
12
17
|
const agents = new Map();
|
|
13
18
|
// AbortController 저장소 (실행 중인 API 호출 취소용)
|
|
@@ -28,10 +33,50 @@ function getClient() {
|
|
|
28
33
|
export function setAgentBot(bot) {
|
|
29
34
|
botInstance = bot;
|
|
30
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* 가장 오래된 Agent 정리 (한도 초과 시)
|
|
38
|
+
*/
|
|
39
|
+
function evictOldestAgent() {
|
|
40
|
+
let oldest = null;
|
|
41
|
+
for (const agent of agents.values()) {
|
|
42
|
+
if (!oldest || agent.createdAt < oldest.createdAt) {
|
|
43
|
+
oldest = agent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (oldest) {
|
|
47
|
+
console.log(`[AgentManager] Evicting oldest agent: ${oldest.id}`);
|
|
48
|
+
// running이면 취소
|
|
49
|
+
if (oldest.status === "running") {
|
|
50
|
+
cancelAgent(oldest.id);
|
|
51
|
+
}
|
|
52
|
+
agents.delete(oldest.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* chatId당 Agent 개수 확인
|
|
57
|
+
*/
|
|
58
|
+
function countAgentsForChat(chatId) {
|
|
59
|
+
let count = 0;
|
|
60
|
+
for (const agent of agents.values()) {
|
|
61
|
+
if (agent.chatId === chatId && agent.status === "running") {
|
|
62
|
+
count++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
31
67
|
/**
|
|
32
68
|
* Sub-agent 생성 및 실행
|
|
33
69
|
*/
|
|
34
70
|
export async function spawnAgent(task, chatId) {
|
|
71
|
+
// chatId당 제한 확인
|
|
72
|
+
const chatAgentCount = countAgentsForChat(chatId);
|
|
73
|
+
if (chatAgentCount >= MAX_AGENTS_PER_CHAT) {
|
|
74
|
+
throw new Error(`이 채팅에서 동시에 실행 가능한 Agent 수(${MAX_AGENTS_PER_CHAT}개)를 초과했습니다. 기존 Agent 완료를 기다려주세요.`);
|
|
75
|
+
}
|
|
76
|
+
// 전체 한도 확인 및 정리
|
|
77
|
+
while (agents.size >= MAX_CONCURRENT_AGENTS) {
|
|
78
|
+
evictOldestAgent();
|
|
79
|
+
}
|
|
35
80
|
const id = randomUUID().slice(0, 8);
|
|
36
81
|
const agent = {
|
|
37
82
|
id,
|
|
@@ -41,6 +86,7 @@ export async function spawnAgent(task, chatId) {
|
|
|
41
86
|
createdAt: new Date(),
|
|
42
87
|
};
|
|
43
88
|
agents.set(id, agent);
|
|
89
|
+
console.log(`[AgentManager] Agent created: ${id} (total: ${agents.size}/${MAX_CONCURRENT_AGENTS})`);
|
|
44
90
|
// 비동기로 agent 실행 (await 하지 않음)
|
|
45
91
|
runAgent(agent).catch((err) => {
|
|
46
92
|
console.error(`[Agent ${id}] Error:`, err);
|
|
@@ -183,35 +229,47 @@ export function getAgent(agentId) {
|
|
|
183
229
|
return agents.get(agentId);
|
|
184
230
|
}
|
|
185
231
|
/**
|
|
186
|
-
* 오래된 agent 정리 (
|
|
187
|
-
* - 완료된 agent: completedAt 기준
|
|
188
|
-
* - running 상태도 createdAt 기준
|
|
232
|
+
* 오래된 agent 정리 (30분 이상)
|
|
233
|
+
* - 완료된 agent: completedAt 기준 30분
|
|
234
|
+
* - running 상태도 createdAt 기준 30분 지나면 정리 (stuck 방지)
|
|
189
235
|
*/
|
|
190
236
|
export function cleanupOldAgents() {
|
|
191
|
-
const
|
|
237
|
+
const cutoff = Date.now() - AGENT_TTL_MS;
|
|
238
|
+
let cleaned = 0;
|
|
192
239
|
for (const [id, agent] of agents.entries()) {
|
|
193
240
|
// 완료된 agent: completedAt 기준
|
|
194
|
-
if (agent.completedAt && agent.completedAt.getTime() <
|
|
241
|
+
if (agent.completedAt && agent.completedAt.getTime() < cutoff) {
|
|
195
242
|
agents.delete(id);
|
|
243
|
+
cleaned++;
|
|
196
244
|
continue;
|
|
197
245
|
}
|
|
198
|
-
// running 상태도
|
|
199
|
-
if (agent.status === "running" && agent.createdAt.getTime() <
|
|
200
|
-
console.log(`[Agent ${id}] Cleaning up stuck agent (running >
|
|
246
|
+
// running 상태도 TTL 지나면 정리 (stuck agent 방지)
|
|
247
|
+
if (agent.status === "running" && agent.createdAt.getTime() < cutoff) {
|
|
248
|
+
console.log(`[Agent ${id}] Cleaning up stuck agent (running > 30min)`);
|
|
249
|
+
// 실행 중인 API 호출 취소
|
|
250
|
+
const controller = abortControllers.get(id);
|
|
251
|
+
if (controller) {
|
|
252
|
+
controller.abort();
|
|
253
|
+
abortControllers.delete(id);
|
|
254
|
+
}
|
|
201
255
|
agents.delete(id);
|
|
256
|
+
cleaned++;
|
|
202
257
|
}
|
|
203
258
|
}
|
|
259
|
+
if (cleaned > 0) {
|
|
260
|
+
console.log(`[AgentManager] Cleanup: removed ${cleaned} agents (remaining: ${agents.size})`);
|
|
261
|
+
}
|
|
204
262
|
}
|
|
205
263
|
// Cleanup interval 참조 저장
|
|
206
264
|
let cleanupIntervalId = null;
|
|
207
265
|
/**
|
|
208
|
-
* 정기 cleanup 시작
|
|
266
|
+
* 정기 cleanup 시작 (30분 주기)
|
|
209
267
|
*/
|
|
210
268
|
export function startCleanup() {
|
|
211
269
|
if (cleanupIntervalId)
|
|
212
270
|
return; // 이미 실행 중
|
|
213
|
-
cleanupIntervalId = setInterval(cleanupOldAgents,
|
|
214
|
-
console.log(
|
|
271
|
+
cleanupIntervalId = setInterval(cleanupOldAgents, CLEANUP_INTERVAL_MS);
|
|
272
|
+
console.log(`[AgentManager] Cleanup interval started (every ${CLEANUP_INTERVAL_MS / 60000}min)`);
|
|
215
273
|
}
|
|
216
274
|
/**
|
|
217
275
|
* 정기 cleanup 중지
|
package/dist/telegram/bot.js
CHANGED
|
@@ -72,9 +72,11 @@ export function createBot(token) {
|
|
|
72
72
|
// 명령어 목록 등록
|
|
73
73
|
bot.api
|
|
74
74
|
.setMyCommands([
|
|
75
|
+
{ command: "help", description: "도움말 보기" },
|
|
76
|
+
{ command: "model", description: "AI 모델 변경" },
|
|
75
77
|
{ command: "compact", description: "대화 정리하기" },
|
|
76
78
|
{ command: "memory", description: "최근 기억 보기" },
|
|
77
|
-
{ command: "
|
|
79
|
+
{ command: "health", description: "봇 상태 확인" },
|
|
78
80
|
])
|
|
79
81
|
.catch((err) => console.error("Failed to set commands:", err));
|
|
80
82
|
// 핸들러 등록
|
|
@@ -60,6 +60,32 @@ import { setHeartbeatConfig, getHeartbeatConfig, disableHeartbeat, } from "../..
|
|
|
60
60
|
import { getWorkspace, invalidateWorkspaceCache, buildSystemPrompt, extractName, } from "../utils/index.js";
|
|
61
61
|
import { ensureDefaultCronJobs } from "../../cron/scheduler.js";
|
|
62
62
|
export function registerCommands(bot) {
|
|
63
|
+
// /help 명령어 - 전체 기능 안내
|
|
64
|
+
bot.command("help", async (ctx) => {
|
|
65
|
+
await ctx.reply(`📖 도움말\n\n` +
|
|
66
|
+
`🎯 기본 기능\n` +
|
|
67
|
+
`/model - AI 모델 변경 (sonnet/opus/haiku)\n` +
|
|
68
|
+
`/compact - 대화 압축해서 토큰 절약\n` +
|
|
69
|
+
`/clear - 대화 초기화\n\n` +
|
|
70
|
+
`📌 기억/핀\n` +
|
|
71
|
+
`/memory - 최근 기억 보기\n` +
|
|
72
|
+
`/pin [내용] - 중요한 정보 핀하기\n` +
|
|
73
|
+
`/pins - 핀 목록 보기\n` +
|
|
74
|
+
`/context - 현재 맥락 상태\n\n` +
|
|
75
|
+
`⏰ 알림/일정\n` +
|
|
76
|
+
`/reminders - 알림 목록\n` +
|
|
77
|
+
`/briefing - 일일 브리핑 켜기/상태\n` +
|
|
78
|
+
`/calendar - 오늘 일정 보기\n\n` +
|
|
79
|
+
`⚙️ 설정\n` +
|
|
80
|
+
`/setup - 기능별 설정 관리\n` +
|
|
81
|
+
`/health - 봇 상태 확인\n` +
|
|
82
|
+
`/reset - 페르소나 초기화\n\n` +
|
|
83
|
+
`💡 자연어로도 말할 수 있어요:\n` +
|
|
84
|
+
`• "opus로 바꿔줘"\n` +
|
|
85
|
+
`• "10분 뒤에 알려줘"\n` +
|
|
86
|
+
`• "기억해: 나는 채식주의자야"\n` +
|
|
87
|
+
`• "내일 일정 뭐야?"`);
|
|
88
|
+
});
|
|
63
89
|
// /start 명령어
|
|
64
90
|
bot.command("start", async (ctx) => {
|
|
65
91
|
const chatId = ctx.chat.id;
|
|
@@ -210,19 +236,19 @@ export function registerCommands(bot) {
|
|
|
210
236
|
const modelList = Object.entries(MODELS)
|
|
211
237
|
.map(([id, m]) => `${id === currentModel ? "→" : " "} /model ${id} - ${m.name}`)
|
|
212
238
|
.join("\n");
|
|
213
|
-
await ctx.reply(
|
|
214
|
-
|
|
215
|
-
|
|
239
|
+
await ctx.reply(`현재 모델: ${MODELS[currentModel].name}\n\n` +
|
|
240
|
+
`사용 가능한 모델:\n${modelList}\n\n` +
|
|
241
|
+
`팁: "모델 바꿔줘"처럼 자연어로도 바꿀 수 있어!`);
|
|
216
242
|
return;
|
|
217
243
|
}
|
|
218
244
|
if (arg in MODELS) {
|
|
219
245
|
const modelId = arg;
|
|
220
246
|
setModel(chatId, modelId);
|
|
221
|
-
await ctx.reply(
|
|
247
|
+
await ctx.reply(`모델 변경됨: ${MODELS[modelId].name}`);
|
|
222
248
|
}
|
|
223
249
|
else {
|
|
224
|
-
await ctx.reply(
|
|
225
|
-
|
|
250
|
+
await ctx.reply(`모르는 모델이야: ${arg}\n\n` +
|
|
251
|
+
`사용 가능: sonnet, opus, haiku`);
|
|
226
252
|
}
|
|
227
253
|
});
|
|
228
254
|
// /setup 명령어 - 추가 기능 설정 및 관리
|
|
@@ -2,10 +2,7 @@ import { MODELS } from "../../ai/claude.js";
|
|
|
2
2
|
import { getWorkspacePath } from "../../workspace/index.js";
|
|
3
3
|
import { getToolsDescription } from "../../tools/index.js";
|
|
4
4
|
import { getWorkspace } from "./cache.js";
|
|
5
|
-
import { embed } from "../../memory/embeddings.js";
|
|
6
|
-
import { search } from "../../memory/vectorStore.js";
|
|
7
5
|
import { buildContextForPrompt, getCurrentChatId } from "../../session/state.js";
|
|
8
|
-
import { SEARCH_CONTEXT_LENGTH, PROMPT_MEMORY_SEARCH_LIMIT, PROMPT_MEMORY_MIN_SCORE, MEMORY_PREVIEW_LENGTH, } from "../../utils/constants.js";
|
|
9
6
|
import * as os from "os";
|
|
10
7
|
function getRuntimeInfo(modelId) {
|
|
11
8
|
const model = MODELS[modelId];
|
|
@@ -49,32 +46,6 @@ export function extractName(identityContent) {
|
|
|
49
46
|
}
|
|
50
47
|
return null;
|
|
51
48
|
}
|
|
52
|
-
// ============== 메모리 검색 ==============
|
|
53
|
-
function extractSearchContext(history) {
|
|
54
|
-
const recent = history.slice(-3);
|
|
55
|
-
return recent
|
|
56
|
-
.filter((m) => m.role === "user")
|
|
57
|
-
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
|
58
|
-
.join(" ")
|
|
59
|
-
.slice(0, SEARCH_CONTEXT_LENGTH);
|
|
60
|
-
}
|
|
61
|
-
async function getRelevantMemories(history) {
|
|
62
|
-
try {
|
|
63
|
-
const context = extractSearchContext(history);
|
|
64
|
-
if (!context.trim())
|
|
65
|
-
return "";
|
|
66
|
-
const queryEmbedding = await embed(context);
|
|
67
|
-
const results = await search(queryEmbedding, PROMPT_MEMORY_SEARCH_LIMIT, PROMPT_MEMORY_MIN_SCORE);
|
|
68
|
-
if (results.length === 0)
|
|
69
|
-
return "";
|
|
70
|
-
return results
|
|
71
|
-
.map((r) => `- (${r.source}): ${r.text.slice(0, MEMORY_PREVIEW_LENGTH)}${r.text.length > MEMORY_PREVIEW_LENGTH ? "..." : ""}`)
|
|
72
|
-
.join("\n");
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return "";
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
49
|
// ============== Core Identity 빌더 ==============
|
|
79
50
|
function buildCoreIdentity(name) {
|
|
80
51
|
const displayName = name || "AI companion";
|
|
@@ -98,11 +69,8 @@ You are NOT a generic assistant or chatbot. You are someone developing a real re
|
|
|
98
69
|
}
|
|
99
70
|
// ============== 메인 빌드 함수 ==============
|
|
100
71
|
export async function buildSystemPrompt(modelId, history) {
|
|
101
|
-
//
|
|
102
|
-
const
|
|
103
|
-
getWorkspace(),
|
|
104
|
-
history && history.length > 0 ? getRelevantMemories(history) : Promise.resolve(""),
|
|
105
|
-
]);
|
|
72
|
+
// 워크스페이스 로드 (메모리 검색은 memory_search 도구로 필요시에만)
|
|
73
|
+
const workspace = await getWorkspace();
|
|
106
74
|
const runtime = getRuntimeInfo(modelId);
|
|
107
75
|
const dateTime = getKoreanDateTime();
|
|
108
76
|
const parts = [];
|
|
@@ -174,15 +142,7 @@ export async function buildSystemPrompt(modelId, history) {
|
|
|
174
142
|
parts.push(workspace.recentDaily);
|
|
175
143
|
parts.push("");
|
|
176
144
|
}
|
|
177
|
-
// 관련
|
|
178
|
-
if (relevantMemoriesResult) {
|
|
179
|
-
parts.push("# Relevant Memories");
|
|
180
|
-
parts.push("");
|
|
181
|
-
parts.push("Related information from older records:");
|
|
182
|
-
parts.push("");
|
|
183
|
-
parts.push(relevantMemoriesResult);
|
|
184
|
-
parts.push("");
|
|
185
|
-
}
|
|
145
|
+
// 관련 기억: memory_search 도구로 필요시에만 검색 (자동 검색 제거됨)
|
|
186
146
|
// 장기 기억
|
|
187
147
|
if (workspace.memory) {
|
|
188
148
|
parts.push("# Long-term Memory");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent tools
|
|
3
|
+
*/
|
|
4
|
+
import { getCurrentChatId } from "../session/state.js";
|
|
5
|
+
import { spawnAgent, listAgents, cancelAgent, } from "../agents/index.js";
|
|
6
|
+
// spawn_agent
|
|
7
|
+
export async function executeSpawnAgent(input) {
|
|
8
|
+
const chatId = getCurrentChatId();
|
|
9
|
+
if (!chatId) {
|
|
10
|
+
return "Error: No active chat session";
|
|
11
|
+
}
|
|
12
|
+
const task = input.task;
|
|
13
|
+
if (!task || task.trim().length === 0) {
|
|
14
|
+
return "Error: Task description is required";
|
|
15
|
+
}
|
|
16
|
+
const agentId = await spawnAgent(task, chatId);
|
|
17
|
+
return `Sub-agent spawned! 🤖\nID: ${agentId}\nTask: ${task.slice(0, 100)}${task.length > 100 ? "..." : ""}\n\nThe agent is working in the background. Results will be sent to this chat when complete.`;
|
|
18
|
+
}
|
|
19
|
+
// list_agents
|
|
20
|
+
export function executeListAgents() {
|
|
21
|
+
const chatId = getCurrentChatId();
|
|
22
|
+
const agents = listAgents(chatId || undefined);
|
|
23
|
+
if (agents.length === 0) {
|
|
24
|
+
return "No sub-agents found.";
|
|
25
|
+
}
|
|
26
|
+
const lines = agents.map((a) => {
|
|
27
|
+
const status = {
|
|
28
|
+
running: "🔄 Running",
|
|
29
|
+
completed: "✅ Completed",
|
|
30
|
+
failed: "❌ Failed",
|
|
31
|
+
cancelled: "⏹️ Cancelled",
|
|
32
|
+
}[a.status];
|
|
33
|
+
const time = a.completedAt
|
|
34
|
+
? `(${Math.round((a.completedAt.getTime() - a.createdAt.getTime()) / 1000)}s)`
|
|
35
|
+
: "";
|
|
36
|
+
return `${a.id}: ${status} ${time}\n Task: ${a.task.slice(0, 60)}${a.task.length > 60 ? "..." : ""}`;
|
|
37
|
+
});
|
|
38
|
+
return `Sub-agents:\n${lines.join("\n\n")}`;
|
|
39
|
+
}
|
|
40
|
+
// cancel_agent
|
|
41
|
+
export function executeCancelAgent(input) {
|
|
42
|
+
const agentId = input.agent_id;
|
|
43
|
+
if (!agentId) {
|
|
44
|
+
return "Error: Agent ID is required";
|
|
45
|
+
}
|
|
46
|
+
const success = cancelAgent(agentId);
|
|
47
|
+
if (success) {
|
|
48
|
+
return `Sub-agent ${agentId} cancelled.`;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return `Could not cancel agent ${agentId}. It may not exist or already completed.`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File operation tools
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "fs/promises";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { isPathAllowed } from "./pathCheck.js";
|
|
7
|
+
// read_file
|
|
8
|
+
export async function executeReadFile(input) {
|
|
9
|
+
const filePath = input.path;
|
|
10
|
+
if (!isPathAllowed(filePath)) {
|
|
11
|
+
return `Error: Access denied. Path not in allowed directories.`;
|
|
12
|
+
}
|
|
13
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
14
|
+
return content;
|
|
15
|
+
}
|
|
16
|
+
// write_file
|
|
17
|
+
export async function executeWriteFile(input) {
|
|
18
|
+
const filePath = input.path;
|
|
19
|
+
const content = input.content;
|
|
20
|
+
if (!isPathAllowed(filePath)) {
|
|
21
|
+
return `Error: Access denied. Path not in allowed directories.`;
|
|
22
|
+
}
|
|
23
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
24
|
+
await fs.writeFile(filePath, content, "utf-8");
|
|
25
|
+
return `File written successfully: ${filePath}`;
|
|
26
|
+
}
|
|
27
|
+
// edit_file
|
|
28
|
+
export async function executeEditFile(input) {
|
|
29
|
+
const filePath = input.path;
|
|
30
|
+
const oldText = input.oldText;
|
|
31
|
+
const newText = input.newText;
|
|
32
|
+
if (!isPathAllowed(filePath)) {
|
|
33
|
+
return `Error: Access denied. Path not in allowed directories.`;
|
|
34
|
+
}
|
|
35
|
+
// 파일 읽기
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return `Error: Could not read file "${filePath}". ${error instanceof Error ? error.message : String(error)}`;
|
|
42
|
+
}
|
|
43
|
+
// oldText 찾기
|
|
44
|
+
const index = content.indexOf(oldText);
|
|
45
|
+
if (index === -1) {
|
|
46
|
+
return `Error: oldText not found in file. Make sure the text matches exactly (including whitespace).`;
|
|
47
|
+
}
|
|
48
|
+
// 첫 번째만 교체
|
|
49
|
+
const newContent = content.slice(0, index) + newText + content.slice(index + oldText.length);
|
|
50
|
+
// 저장
|
|
51
|
+
await fs.writeFile(filePath, newContent, "utf-8");
|
|
52
|
+
return `File edited successfully: ${filePath}`;
|
|
53
|
+
}
|
|
54
|
+
// list_directory
|
|
55
|
+
export async function executeListDirectory(input) {
|
|
56
|
+
const dirPath = input.path;
|
|
57
|
+
if (!isPathAllowed(dirPath)) {
|
|
58
|
+
return `Error: Access denied. Path not in allowed directories.`;
|
|
59
|
+
}
|
|
60
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
61
|
+
const list = entries.map((e) => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`);
|
|
62
|
+
return list.join("\n");
|
|
63
|
+
}
|