@pollit/twin-dev-bot 0.0.1
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/LICENSE +661 -0
- package/README.md +415 -0
- package/bin/twindevbot.js +22 -0
- package/dist/action-payload-store.d.ts +22 -0
- package/dist/action-payload-store.js +54 -0
- package/dist/active-runners.d.ts +44 -0
- package/dist/active-runners.js +114 -0
- package/dist/channel-store.d.ts +16 -0
- package/dist/channel-store.js +91 -0
- package/dist/claude/active-runners.d.ts +44 -0
- package/dist/claude/active-runners.js +114 -0
- package/dist/claude/claude-runner.d.ts +57 -0
- package/dist/claude/claude-runner.js +210 -0
- package/dist/claude/session-manager.d.ts +62 -0
- package/dist/claude/session-manager.js +247 -0
- package/dist/claude-runner.d.ts +57 -0
- package/dist/claude-runner.js +210 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +271 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +49 -0
- package/dist/conversation-store.d.ts +53 -0
- package/dist/conversation-store.js +173 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +49 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +110 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +18 -0
- package/dist/core/platform.d.ts +18 -0
- package/dist/core/platform.js +33 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +14 -0
- package/dist/daemon/macos.d.ts +8 -0
- package/dist/daemon/macos.js +150 -0
- package/dist/daemon/types.d.ts +9 -0
- package/dist/daemon/types.js +1 -0
- package/dist/daemon/windows.d.ts +8 -0
- package/dist/daemon/windows.js +137 -0
- package/dist/handlers/claude-command.d.ts +2 -0
- package/dist/handlers/claude-command.js +634 -0
- package/dist/handlers/claude-runner-setup.d.ts +16 -0
- package/dist/handlers/claude-runner-setup.js +445 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/init-handlers.d.ts +2 -0
- package/dist/handlers/init-handlers.js +189 -0
- package/dist/handlers/question-handlers.d.ts +2 -0
- package/dist/handlers/question-handlers.js +835 -0
- package/dist/i18n/en.d.ts +150 -0
- package/dist/i18n/en.js +163 -0
- package/dist/i18n/index.d.ts +20 -0
- package/dist/i18n/index.js +31 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +141 -0
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +110 -0
- package/dist/multi-select-state.d.ts +58 -0
- package/dist/multi-select-state.js +151 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +18 -0
- package/dist/pending-questions.d.ts +53 -0
- package/dist/pending-questions.js +139 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +33 -0
- package/dist/progress-tracker.d.ts +47 -0
- package/dist/progress-tracker.js +218 -0
- package/dist/question-blocks.d.ts +27 -0
- package/dist/question-blocks.js +235 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +83 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +247 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +132 -0
- package/dist/slack/progress-tracker.d.ts +47 -0
- package/dist/slack/progress-tracker.js +218 -0
- package/dist/slack/question-blocks.d.ts +27 -0
- package/dist/slack/question-blocks.js +235 -0
- package/dist/stores/action-payload-store.d.ts +22 -0
- package/dist/stores/action-payload-store.js +54 -0
- package/dist/stores/channel-store.d.ts +16 -0
- package/dist/stores/channel-store.js +91 -0
- package/dist/stores/multi-select-state.d.ts +58 -0
- package/dist/stores/multi-select-state.js +151 -0
- package/dist/stores/pending-questions.d.ts +53 -0
- package/dist/stores/pending-questions.js +139 -0
- package/dist/stores/workspace-store.d.ts +27 -0
- package/dist/stores/workspace-store.js +160 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +292 -0
- package/dist/types/claude-stream.d.ts +116 -0
- package/dist/types/claude-stream.js +3 -0
- package/dist/types/conversation.d.ts +16 -0
- package/dist/types/conversation.js +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/slack.d.ts +51 -0
- package/dist/types/slack.js +1 -0
- package/dist/utils/display-width.d.ts +8 -0
- package/dist/utils/display-width.js +33 -0
- package/dist/utils/safe-async.d.ts +6 -0
- package/dist/utils/safe-async.js +14 -0
- package/dist/utils/slack-message.d.ts +73 -0
- package/dist/utils/slack-message.js +220 -0
- package/dist/utils/slack-rate-limit.d.ts +5 -0
- package/dist/utils/slack-rate-limit.js +49 -0
- package/dist/workspace-store.d.ts +27 -0
- package/dist/workspace-store.js +160 -0
- package/package.json +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
4
|
+
import { confirm } from "@inquirer/prompts";
|
|
5
|
+
import { ENV_FILE, LOG_ERR, SESSIONS_FILE, WORKSPACES_FILE, ensureDirs } from "./core/paths.js";
|
|
6
|
+
import { initLocale, t } from "./i18n/index.js";
|
|
7
|
+
import { ensureConfig } from "./setup.js";
|
|
8
|
+
import { isDaemonSupported } from "./core/platform.js";
|
|
9
|
+
import { createDaemonManager } from "./daemon/index.js";
|
|
10
|
+
import { getDisplayWidth } from "./utils/display-width.js";
|
|
11
|
+
// .env 로드 → locale 초기화
|
|
12
|
+
dotenv.config({ path: ENV_FILE, override: true });
|
|
13
|
+
initLocale();
|
|
14
|
+
function wrapText(text, maxWidth) {
|
|
15
|
+
const words = text.split(" ");
|
|
16
|
+
const lines = [];
|
|
17
|
+
let line = "";
|
|
18
|
+
let lineWidth = 0;
|
|
19
|
+
for (const word of words) {
|
|
20
|
+
const wordWidth = getDisplayWidth(word);
|
|
21
|
+
const gap = line ? 1 : 0;
|
|
22
|
+
if (lineWidth + gap + wordWidth > maxWidth && line) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
line = word;
|
|
25
|
+
lineWidth = wordWidth;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
line = line ? line + " " + word : word;
|
|
29
|
+
lineWidth += gap + wordWidth;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (line)
|
|
33
|
+
lines.push(line);
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
function buildWarningBox(text, innerWidth) {
|
|
37
|
+
const red = "\x1b[31m";
|
|
38
|
+
const reset = "\x1b[0m";
|
|
39
|
+
const lines = wrapText(text, innerWidth);
|
|
40
|
+
const border = "─".repeat(innerWidth + 2);
|
|
41
|
+
const top = ` ${red}┌${border}┐${reset}`;
|
|
42
|
+
const bottom = ` ${red}└${border}┘${reset}`;
|
|
43
|
+
const empty = ` ${red}│${" ".repeat(innerWidth + 2)}│${reset}`;
|
|
44
|
+
const content = lines.map((l) => {
|
|
45
|
+
const pad = innerWidth - getDisplayWidth(l);
|
|
46
|
+
return ` ${red}│ ${l}${" ".repeat(pad)} │${reset}`;
|
|
47
|
+
});
|
|
48
|
+
return [
|
|
49
|
+
"",
|
|
50
|
+
` ${red}${t("cli.warning.title")}${reset}`,
|
|
51
|
+
top,
|
|
52
|
+
empty,
|
|
53
|
+
...content,
|
|
54
|
+
empty,
|
|
55
|
+
bottom,
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
function getLogViewHint() {
|
|
59
|
+
if (process.platform === "win32") {
|
|
60
|
+
return `Get-Content "${LOG_ERR}" -Wait`;
|
|
61
|
+
}
|
|
62
|
+
return `tail -f "${LOG_ERR}"`;
|
|
63
|
+
}
|
|
64
|
+
function printHelp() {
|
|
65
|
+
console.log(`
|
|
66
|
+
twindevbot - ${t("cli.description")}
|
|
67
|
+
|
|
68
|
+
${t("cli.usage")}
|
|
69
|
+
twindevbot <command> [options]
|
|
70
|
+
|
|
71
|
+
${t("cli.commands")}
|
|
72
|
+
start ${t("cli.cmd.start")}
|
|
73
|
+
start --daemon, -d ${t("cli.cmd.startDaemon")}
|
|
74
|
+
stop ${t("cli.cmd.stop")}
|
|
75
|
+
status ${t("cli.cmd.status")}
|
|
76
|
+
show ${t("cli.cmd.show")}
|
|
77
|
+
clear ${t("cli.cmd.clear")}
|
|
78
|
+
help ${t("cli.cmd.help")}
|
|
79
|
+
|
|
80
|
+
${t("cli.notes")}
|
|
81
|
+
1. ${t("cli.notes.daemon")}
|
|
82
|
+
2. ${t("cli.notes.errorLog")}
|
|
83
|
+
${LOG_ERR}
|
|
84
|
+
${buildWarningBox(t("cli.warning.text"), 62)}
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
function ensureDaemonSupported() {
|
|
88
|
+
if (!isDaemonSupported()) {
|
|
89
|
+
console.error(t("cli.daemonUnsupportedPlatform", { platform: process.platform }));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function startDaemon() {
|
|
94
|
+
ensureDaemonSupported();
|
|
95
|
+
const manager = await createDaemonManager();
|
|
96
|
+
manager.start();
|
|
97
|
+
}
|
|
98
|
+
async function stopDaemon() {
|
|
99
|
+
ensureDaemonSupported();
|
|
100
|
+
const manager = await createDaemonManager();
|
|
101
|
+
manager.stop();
|
|
102
|
+
}
|
|
103
|
+
async function showStatus() {
|
|
104
|
+
ensureDaemonSupported();
|
|
105
|
+
const manager = await createDaemonManager();
|
|
106
|
+
manager.status();
|
|
107
|
+
}
|
|
108
|
+
function formatDateTime(dateStr) {
|
|
109
|
+
const d = new Date(dateStr);
|
|
110
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
111
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
112
|
+
}
|
|
113
|
+
function formatRelativeTime(dateStr) {
|
|
114
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
115
|
+
const minutes = Math.floor(diff / 60_000);
|
|
116
|
+
const hours = Math.floor(minutes / 60);
|
|
117
|
+
const days = Math.floor(hours / 24);
|
|
118
|
+
if (days > 0)
|
|
119
|
+
return t("cli.show.daysAgo", { n: days });
|
|
120
|
+
if (hours > 0)
|
|
121
|
+
return t("cli.show.hoursAgo", { n: hours });
|
|
122
|
+
if (minutes > 0)
|
|
123
|
+
return t("cli.show.minutesAgo", { n: minutes });
|
|
124
|
+
return t("cli.show.justNow");
|
|
125
|
+
}
|
|
126
|
+
function showSessions() {
|
|
127
|
+
const dim = "\x1b[2m";
|
|
128
|
+
const bold = "\x1b[1m";
|
|
129
|
+
const cyan = "\x1b[36m";
|
|
130
|
+
const yellow = "\x1b[33m";
|
|
131
|
+
const green = "\x1b[32m";
|
|
132
|
+
const magenta = "\x1b[35m";
|
|
133
|
+
const rst = "\x1b[0m";
|
|
134
|
+
if (!existsSync(SESSIONS_FILE)) {
|
|
135
|
+
console.log(`\n ${t("cli.show.noSessions")}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let data;
|
|
139
|
+
try {
|
|
140
|
+
data = JSON.parse(readFileSync(SESSIONS_FILE, "utf-8"));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
console.error(`\n ${t("cli.show.parseError")}\n`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!data.sessions || data.sessions.length === 0) {
|
|
147
|
+
console.log(`\n ${t("cli.show.noSessions")}\n`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// group by projectName
|
|
151
|
+
const grouped = new Map();
|
|
152
|
+
for (const s of data.sessions) {
|
|
153
|
+
const arr = grouped.get(s.projectName) || [];
|
|
154
|
+
arr.push(s);
|
|
155
|
+
grouped.set(s.projectName, arr);
|
|
156
|
+
}
|
|
157
|
+
// header box
|
|
158
|
+
const total = data.sessions.length;
|
|
159
|
+
const title = t("cli.show.title", { count: total });
|
|
160
|
+
const titleWidth = getDisplayWidth(title);
|
|
161
|
+
const innerWidth = Math.max(titleWidth + 4, 38);
|
|
162
|
+
const padLeft = Math.floor((innerWidth - titleWidth) / 2);
|
|
163
|
+
const padRight = innerWidth - titleWidth - padLeft;
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(` ${dim}┌${"─".repeat(innerWidth)}┐${rst}`);
|
|
166
|
+
console.log(` ${dim}│${rst}${" ".repeat(padLeft)}${bold}${title}${rst}${" ".repeat(padRight)}${dim}│${rst}`);
|
|
167
|
+
console.log(` ${dim}└${"─".repeat(innerWidth)}┘${rst}`);
|
|
168
|
+
for (const [projectName, sessions] of grouped) {
|
|
169
|
+
const countLabel = t("cli.show.sessionCount", { count: sessions.length });
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log(` ${bold}${cyan}${projectName}${rst} ${dim}${countLabel}${rst}`);
|
|
172
|
+
sessions.forEach((s, i) => {
|
|
173
|
+
const isLast = i === sessions.length - 1;
|
|
174
|
+
const prefix = isLast ? "└─" : "├─";
|
|
175
|
+
const sid = s.sessionId.length > 12 ? s.sessionId.slice(0, 12) + "…" : s.sessionId;
|
|
176
|
+
const started = formatDateTime(s.startedAt);
|
|
177
|
+
const lastAct = formatRelativeTime(s.lastActivityAt);
|
|
178
|
+
const autopilotBadge = s.autopilot ? ` ${magenta}autopilot${rst}` : "";
|
|
179
|
+
console.log(` ${dim}${prefix}${rst} ${yellow}${sid}${rst} ${dim}${started}${rst} ${green}${lastAct}${rst}${autopilotBadge}`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
console.log("");
|
|
183
|
+
}
|
|
184
|
+
// ── clear command ─────────────────────────────────────────────
|
|
185
|
+
async function clearData() {
|
|
186
|
+
// 데몬이 실행 중이면 경고
|
|
187
|
+
if (isDaemonSupported()) {
|
|
188
|
+
try {
|
|
189
|
+
const manager = await createDaemonManager();
|
|
190
|
+
if (manager.isRunning()) {
|
|
191
|
+
console.log(`\n ${t("cli.clear.daemonRunning")}\n`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// 데몬 상태 확인 실패 → 무시하고 진행
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const targets = [SESSIONS_FILE, WORKSPACES_FILE].filter((f) => existsSync(f));
|
|
200
|
+
if (targets.length === 0) {
|
|
201
|
+
console.log(`\n ${t("cli.clear.noData")}\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.log(`\n ${t("cli.clear.header")}`);
|
|
205
|
+
for (const f of targets) {
|
|
206
|
+
console.log(` • ${f}`);
|
|
207
|
+
}
|
|
208
|
+
console.log("");
|
|
209
|
+
const ok = await confirm({ message: t("cli.clear.confirm"), default: false });
|
|
210
|
+
if (!ok) {
|
|
211
|
+
console.log(`\n ${t("cli.clear.cancelled")}\n`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const f of targets) {
|
|
215
|
+
unlinkSync(f);
|
|
216
|
+
}
|
|
217
|
+
console.log(`\n ${t("cli.clear.done")}\n`);
|
|
218
|
+
}
|
|
219
|
+
// ── start command ─────────────────────────────────────────────
|
|
220
|
+
async function start(daemon) {
|
|
221
|
+
ensureDirs();
|
|
222
|
+
await ensureConfig(daemon);
|
|
223
|
+
if (daemon) {
|
|
224
|
+
await startDaemon();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// 포그라운드 실행 시 로그를 파일에도 기록
|
|
228
|
+
// (daemon 모드에서는 launchd가 stderr를 파일로 리다이렉트하므로 중복 방지)
|
|
229
|
+
if (process.stderr.isTTY) {
|
|
230
|
+
const { enableFileLogging } = await import("./core/logger.js");
|
|
231
|
+
enableFileLogging(LOG_ERR);
|
|
232
|
+
}
|
|
233
|
+
await import("./server.js");
|
|
234
|
+
}
|
|
235
|
+
const command = process.argv[2];
|
|
236
|
+
const flags = process.argv.slice(3);
|
|
237
|
+
const isDaemon = flags.includes("--daemon") || flags.includes("-d");
|
|
238
|
+
(async () => {
|
|
239
|
+
switch (command) {
|
|
240
|
+
case "start":
|
|
241
|
+
await start(isDaemon);
|
|
242
|
+
break;
|
|
243
|
+
case undefined:
|
|
244
|
+
printHelp();
|
|
245
|
+
break;
|
|
246
|
+
case "stop":
|
|
247
|
+
await stopDaemon();
|
|
248
|
+
break;
|
|
249
|
+
case "status":
|
|
250
|
+
await showStatus();
|
|
251
|
+
break;
|
|
252
|
+
case "show":
|
|
253
|
+
showSessions();
|
|
254
|
+
break;
|
|
255
|
+
case "clear":
|
|
256
|
+
await clearData();
|
|
257
|
+
break;
|
|
258
|
+
case "help":
|
|
259
|
+
case "--help":
|
|
260
|
+
case "-h":
|
|
261
|
+
printHelp();
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
console.error(t("cli.unknownCommand", { command }));
|
|
265
|
+
printHelp();
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
})().catch((error) => {
|
|
269
|
+
console.error("Fatal error:", error);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import { ENV_FILE } from "./paths.js";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
import { expandTilde, getDefaultBaseDir } from "./platform.js";
|
|
5
|
+
const log = createLogger("config");
|
|
6
|
+
let _initialized = false;
|
|
7
|
+
function ensureInit() {
|
|
8
|
+
if (_initialized)
|
|
9
|
+
return;
|
|
10
|
+
_initialized = true;
|
|
11
|
+
dotenv.config({ path: ENV_FILE, override: true });
|
|
12
|
+
const missing = [];
|
|
13
|
+
if (!process.env.SLACK_BOT_TOKEN)
|
|
14
|
+
missing.push("SLACK_BOT_TOKEN");
|
|
15
|
+
if (!process.env.SLACK_APP_TOKEN)
|
|
16
|
+
missing.push("SLACK_APP_TOKEN");
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
throw new Error(`Missing required environment variables: ${missing.join(", ")}. ` +
|
|
19
|
+
`Please configure them in your .env file.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const config = {
|
|
23
|
+
get slack() {
|
|
24
|
+
ensureInit();
|
|
25
|
+
return {
|
|
26
|
+
botToken: process.env.SLACK_BOT_TOKEN,
|
|
27
|
+
appToken: process.env.SLACK_APP_TOKEN,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
get baseDir() {
|
|
31
|
+
ensureInit();
|
|
32
|
+
const raw = process.env.TWINDEVBOT_BASE_DIR;
|
|
33
|
+
return raw ? expandTilde(raw) : getDefaultBaseDir();
|
|
34
|
+
},
|
|
35
|
+
get inactivityTimeoutMinutes() {
|
|
36
|
+
ensureInit();
|
|
37
|
+
const DEFAULT_MINUTES = 30;
|
|
38
|
+
const raw = process.env.INACTIVITY_TIMEOUT_MINUTES;
|
|
39
|
+
const parsed = parseInt(raw || String(DEFAULT_MINUTES), 10);
|
|
40
|
+
if (isNaN(parsed) || parsed < 1) {
|
|
41
|
+
log.warn(`Invalid INACTIVITY_TIMEOUT_MINUTES="${raw}", using default ${DEFAULT_MINUTES} minutes`);
|
|
42
|
+
return DEFAULT_MINUTES;
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
},
|
|
46
|
+
get inactivityTimeoutMs() {
|
|
47
|
+
return this.inactivityTimeoutMinutes * 60 * 1000;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 대화 파일 관리 모듈
|
|
3
|
+
*
|
|
4
|
+
* 각 프로젝트의 작업 디렉토리에 conversation 폴더를 생성하고 대화를 관리합니다.
|
|
5
|
+
* 파일 경로:
|
|
6
|
+
* - {directory}/conversation/dialogue.json - 처리 완료된 메시지 기록 (SAVE_DIALOGUE=true 시)
|
|
7
|
+
* - {directory}/conversation/claude-output-raw.jsonl - Claude CLI 원본 출력 (SAVE_CLAUDE_OUTPUT_RAW=true 시)
|
|
8
|
+
*/
|
|
9
|
+
import type { ConversationFile, Message, MessageType, MessageContent } from "./types/conversation.js";
|
|
10
|
+
/**
|
|
11
|
+
* 작업 디렉토리에서 conversation 디렉토리 경로 반환
|
|
12
|
+
*/
|
|
13
|
+
export declare function getConversationDir(directory: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* dialogue.json 파일 경로 반환
|
|
16
|
+
*/
|
|
17
|
+
export declare function getDialoguePath(directory: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* claude-output-raw.jsonl 파일 경로 반환
|
|
20
|
+
*/
|
|
21
|
+
export declare function getRawOutputPath(directory: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* conversation 디렉토리가 존재하는지 확인하고, 없으면 생성
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureConversationDir(directory: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* 대화 파일 로드
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadConversation(directory: string): ConversationFile | null;
|
|
30
|
+
/**
|
|
31
|
+
* 대화 파일 저장
|
|
32
|
+
*/
|
|
33
|
+
export declare function saveConversation(directory: string, conversation: ConversationFile): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 새 대화 파일 생성
|
|
36
|
+
*/
|
|
37
|
+
export declare function createConversation(projectName: string, directory: string, slackChannelId: string): ConversationFile | null;
|
|
38
|
+
/**
|
|
39
|
+
* 대화 파일 로드 또는 생성
|
|
40
|
+
*/
|
|
41
|
+
export declare function getOrCreateConversation(projectName: string, directory: string, slackChannelId: string): ConversationFile | null;
|
|
42
|
+
/**
|
|
43
|
+
* 처리 완료된 메시지 기록
|
|
44
|
+
*
|
|
45
|
+
* dialogue.json에는 처리가 성공적으로 끝난 것만 기록됩니다.
|
|
46
|
+
* - Claude 메시지: Slack 전송 성공 후 기록
|
|
47
|
+
* - 사용자 메시지: Claude 입력 성공 후 기록
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordMessage(directory: string, type: MessageType, content: MessageContent): Message | null;
|
|
50
|
+
/**
|
|
51
|
+
* Claude 세션 ID 업데이트
|
|
52
|
+
*/
|
|
53
|
+
export declare function updateClaudeSessionId(directory: string, sessionId: string): boolean;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 대화 파일 관리 모듈
|
|
3
|
+
*
|
|
4
|
+
* 각 프로젝트의 작업 디렉토리에 conversation 폴더를 생성하고 대화를 관리합니다.
|
|
5
|
+
* 파일 경로:
|
|
6
|
+
* - {directory}/conversation/dialogue.json - 처리 완료된 메시지 기록 (SAVE_DIALOGUE=true 시)
|
|
7
|
+
* - {directory}/conversation/claude-output-raw.jsonl - Claude CLI 원본 출력 (SAVE_CLAUDE_OUTPUT_RAW=true 시)
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { createLogger } from "./logger.js";
|
|
13
|
+
import { config } from "./config.js";
|
|
14
|
+
const log = createLogger("conversation-store");
|
|
15
|
+
/**
|
|
16
|
+
* 작업 디렉토리에서 conversation 디렉토리 경로 반환
|
|
17
|
+
*/
|
|
18
|
+
export function getConversationDir(directory) {
|
|
19
|
+
return join(directory, "conversation");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* dialogue.json 파일 경로 반환
|
|
23
|
+
*/
|
|
24
|
+
export function getDialoguePath(directory) {
|
|
25
|
+
return join(getConversationDir(directory), "dialogue.json");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* claude-output-raw.jsonl 파일 경로 반환
|
|
29
|
+
*/
|
|
30
|
+
export function getRawOutputPath(directory) {
|
|
31
|
+
return join(getConversationDir(directory), "claude-output-raw.jsonl");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* conversation 디렉토리가 존재하는지 확인하고, 없으면 생성
|
|
35
|
+
*/
|
|
36
|
+
export function ensureConversationDir(directory) {
|
|
37
|
+
const conversationDir = getConversationDir(directory);
|
|
38
|
+
if (!existsSync(conversationDir)) {
|
|
39
|
+
mkdirSync(conversationDir, { recursive: true });
|
|
40
|
+
log.debug("Created conversation directory", { dir: conversationDir });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 대화 파일 로드
|
|
45
|
+
*/
|
|
46
|
+
export function loadConversation(directory) {
|
|
47
|
+
if (!config.dialogue.save) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const filePath = getDialoguePath(directory);
|
|
51
|
+
if (!existsSync(filePath)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const content = readFileSync(filePath, "utf-8");
|
|
56
|
+
return JSON.parse(content);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
log.error("Failed to load dialogue file", { directory, error });
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 대화 파일 저장
|
|
65
|
+
*/
|
|
66
|
+
export function saveConversation(directory, conversation) {
|
|
67
|
+
if (!config.dialogue.save) {
|
|
68
|
+
return true; // 저장 비활성화 시 성공으로 처리
|
|
69
|
+
}
|
|
70
|
+
ensureConversationDir(directory);
|
|
71
|
+
const filePath = getDialoguePath(directory);
|
|
72
|
+
try {
|
|
73
|
+
conversation.updatedAt = new Date().toISOString();
|
|
74
|
+
writeFileSync(filePath, JSON.stringify(conversation, null, 2));
|
|
75
|
+
log.debug("Dialogue saved", { directory });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
log.error("Failed to save dialogue file", { directory, error });
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 새 대화 파일 생성
|
|
85
|
+
*/
|
|
86
|
+
export function createConversation(projectName, directory, slackChannelId) {
|
|
87
|
+
if (!config.dialogue.save) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
ensureConversationDir(directory);
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const conversation = {
|
|
93
|
+
projectName,
|
|
94
|
+
directory,
|
|
95
|
+
slackChannelId,
|
|
96
|
+
claudeSessionId: null,
|
|
97
|
+
createdAt: now,
|
|
98
|
+
updatedAt: now,
|
|
99
|
+
messages: [],
|
|
100
|
+
};
|
|
101
|
+
saveConversation(directory, conversation);
|
|
102
|
+
log.info("Created new dialogue", { projectName, directory });
|
|
103
|
+
return conversation;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 대화 파일 로드 또는 생성
|
|
107
|
+
*/
|
|
108
|
+
export function getOrCreateConversation(projectName, directory, slackChannelId) {
|
|
109
|
+
if (!config.dialogue.save) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const existing = loadConversation(directory);
|
|
113
|
+
if (existing) {
|
|
114
|
+
// 기존 대화가 있으면 채널 ID 업데이트 (다른 채널에서 시작할 수 있음)
|
|
115
|
+
existing.slackChannelId = slackChannelId;
|
|
116
|
+
saveConversation(directory, existing);
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
return createConversation(projectName, directory, slackChannelId);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 메시지 ID 생성
|
|
123
|
+
*/
|
|
124
|
+
function generateMessageId() {
|
|
125
|
+
return randomUUID();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 처리 완료된 메시지 기록
|
|
129
|
+
*
|
|
130
|
+
* dialogue.json에는 처리가 성공적으로 끝난 것만 기록됩니다.
|
|
131
|
+
* - Claude 메시지: Slack 전송 성공 후 기록
|
|
132
|
+
* - 사용자 메시지: Claude 입력 성공 후 기록
|
|
133
|
+
*/
|
|
134
|
+
export function recordMessage(directory, type, content) {
|
|
135
|
+
if (!config.dialogue.save) {
|
|
136
|
+
return null; // 저장 비활성화 시 건너뜀
|
|
137
|
+
}
|
|
138
|
+
let conversation = loadConversation(directory);
|
|
139
|
+
if (!conversation) {
|
|
140
|
+
log.error("Dialogue not found", { directory });
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const message = {
|
|
144
|
+
id: generateMessageId(),
|
|
145
|
+
type,
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
processed: true, // 기록 시점에 이미 처리 완료
|
|
148
|
+
content,
|
|
149
|
+
};
|
|
150
|
+
conversation.messages.push(message);
|
|
151
|
+
saveConversation(directory, conversation);
|
|
152
|
+
log.debug("Message recorded", {
|
|
153
|
+
directory,
|
|
154
|
+
messageId: message.id,
|
|
155
|
+
type,
|
|
156
|
+
});
|
|
157
|
+
return message;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Claude 세션 ID 업데이트
|
|
161
|
+
*/
|
|
162
|
+
export function updateClaudeSessionId(directory, sessionId) {
|
|
163
|
+
if (!config.dialogue.save) {
|
|
164
|
+
return true; // 저장 비활성화 시 성공으로 처리
|
|
165
|
+
}
|
|
166
|
+
const conversation = loadConversation(directory);
|
|
167
|
+
if (!conversation) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
conversation.claudeSessionId = sessionId;
|
|
171
|
+
saveConversation(directory, conversation);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import { ENV_FILE } from "./paths.js";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
import { expandTilde, getDefaultBaseDir } from "./platform.js";
|
|
5
|
+
const log = createLogger("config");
|
|
6
|
+
let _initialized = false;
|
|
7
|
+
function ensureInit() {
|
|
8
|
+
if (_initialized)
|
|
9
|
+
return;
|
|
10
|
+
_initialized = true;
|
|
11
|
+
dotenv.config({ path: ENV_FILE, override: true });
|
|
12
|
+
const missing = [];
|
|
13
|
+
if (!process.env.SLACK_BOT_TOKEN)
|
|
14
|
+
missing.push("SLACK_BOT_TOKEN");
|
|
15
|
+
if (!process.env.SLACK_APP_TOKEN)
|
|
16
|
+
missing.push("SLACK_APP_TOKEN");
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
throw new Error(`Missing required environment variables: ${missing.join(", ")}. ` +
|
|
19
|
+
`Please configure them in your .env file.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const config = {
|
|
23
|
+
get slack() {
|
|
24
|
+
ensureInit();
|
|
25
|
+
return {
|
|
26
|
+
botToken: process.env.SLACK_BOT_TOKEN,
|
|
27
|
+
appToken: process.env.SLACK_APP_TOKEN,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
get baseDir() {
|
|
31
|
+
ensureInit();
|
|
32
|
+
const raw = process.env.TWINDEVBOT_BASE_DIR;
|
|
33
|
+
return raw ? expandTilde(raw) : getDefaultBaseDir();
|
|
34
|
+
},
|
|
35
|
+
get inactivityTimeoutMinutes() {
|
|
36
|
+
ensureInit();
|
|
37
|
+
const DEFAULT_MINUTES = 30;
|
|
38
|
+
const raw = process.env.INACTIVITY_TIMEOUT_MINUTES;
|
|
39
|
+
const parsed = parseInt(raw || String(DEFAULT_MINUTES), 10);
|
|
40
|
+
if (isNaN(parsed) || parsed < 1) {
|
|
41
|
+
log.warn(`Invalid INACTIVITY_TIMEOUT_MINUTES="${raw}", using default ${DEFAULT_MINUTES} minutes`);
|
|
42
|
+
return DEFAULT_MINUTES;
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
},
|
|
46
|
+
get inactivityTimeoutMs() {
|
|
47
|
+
return this.inactivityTimeoutMinutes * 60 * 1000;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
declare class Logger {
|
|
3
|
+
private level;
|
|
4
|
+
private context?;
|
|
5
|
+
private static logStream;
|
|
6
|
+
constructor(level?: LogLevel, context?: string);
|
|
7
|
+
static enableFileLogging(filePath: string): void;
|
|
8
|
+
private shouldLog;
|
|
9
|
+
private formatMessage;
|
|
10
|
+
private output;
|
|
11
|
+
debug(message: string, data?: unknown): void;
|
|
12
|
+
info(message: string, data?: unknown): void;
|
|
13
|
+
warn(message: string, data?: unknown): void;
|
|
14
|
+
error(message: string, error?: unknown): void;
|
|
15
|
+
child(context: string): Logger;
|
|
16
|
+
}
|
|
17
|
+
export declare const logger: Logger;
|
|
18
|
+
export declare function createLogger(context: string): Logger;
|
|
19
|
+
export declare function enableFileLogging(filePath: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Slack Bolt용 로거 어댑터.
|
|
22
|
+
* Bolt 내부 로그를 커스텀 로거로 라우팅하여
|
|
23
|
+
* 타임스탬프 + stderr 출력을 일관되게 유지한다.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createBoltLogger(): {
|
|
26
|
+
debug(...msg: unknown[]): void;
|
|
27
|
+
info(...msg: unknown[]): void;
|
|
28
|
+
warn(...msg: unknown[]): void;
|
|
29
|
+
error(...msg: unknown[]): void;
|
|
30
|
+
setLevel(level: string): void;
|
|
31
|
+
getLevel(): string;
|
|
32
|
+
setName(name: string): void;
|
|
33
|
+
};
|
|
34
|
+
export {};
|