@haenah/u1z 0.1.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.
Files changed (49) hide show
  1. package/README.md +284 -0
  2. package/migrations/001_conversations.sql +23 -0
  3. package/package.json +50 -0
  4. package/src/ai/llm/model.ts +15 -0
  5. package/src/ai/llm/tools/analyzeYoutube.ts +227 -0
  6. package/src/ai/llm/tools/bash.test.ts +24 -0
  7. package/src/ai/llm/tools/bash.ts +20 -0
  8. package/src/ai/llm/tools/tavilyClient.ts +3 -0
  9. package/src/ai/llm/tools/textEditor.test.ts +91 -0
  10. package/src/ai/llm/tools/textEditor.ts +87 -0
  11. package/src/ai/llm/tools/webFetch.ts +41 -0
  12. package/src/ai/llm/tools/webSearch.ts +84 -0
  13. package/src/cli/commands/doctor.ts +138 -0
  14. package/src/cli/commands/init.ts +130 -0
  15. package/src/cli/commands/logs.ts +11 -0
  16. package/src/cli/commands/server.ts +28 -0
  17. package/src/cli/commands/status.ts +8 -0
  18. package/src/cli/commands/update.ts +21 -0
  19. package/src/cli/index.ts +29 -0
  20. package/src/cli/utils/color.ts +7 -0
  21. package/src/cli/utils/prompt.ts +16 -0
  22. package/src/conversation/basePrompt.test.ts +43 -0
  23. package/src/conversation/conversation.test.ts +197 -0
  24. package/src/conversation/conversation.ts +156 -0
  25. package/src/conversation/manager.test.ts +108 -0
  26. package/src/conversation/manager.ts +72 -0
  27. package/src/conversation/messages.test.ts +112 -0
  28. package/src/conversation/messages.ts +63 -0
  29. package/src/conversation/systemPrompt.ts +60 -0
  30. package/src/db/conversationStore.ts +100 -0
  31. package/src/db/index.ts +21 -0
  32. package/src/db/migrator.test.ts +129 -0
  33. package/src/db/migrator.ts +120 -0
  34. package/src/discord/client.ts +11 -0
  35. package/src/discord/handlers/interactionCreate.ts +69 -0
  36. package/src/discord/handlers/messageCreate.test.ts +49 -0
  37. package/src/discord/handlers/messageCreate.ts +180 -0
  38. package/src/discord/index.ts +49 -0
  39. package/src/discord/systemPrompt.test.ts +30 -0
  40. package/src/env.d.ts +28 -0
  41. package/src/memory/compress.ts +102 -0
  42. package/src/memory/index.test.ts +84 -0
  43. package/src/memory/index.ts +103 -0
  44. package/src/memory/memorize.ts +38 -0
  45. package/src/memory/types.ts +1 -0
  46. package/tsconfig.json +24 -0
  47. package/u1z_home_bootstrap/.u1z/prompt/BASE.md +41 -0
  48. package/u1z_home_bootstrap/.u1z/prompt/DREAM.md +12 -0
  49. package/u1z_home_bootstrap/.u1z/prompt/MEMORIZE.md +11 -0
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModelMessage } from "ai";
3
+ import { serializeMessage } from "./messages";
4
+
5
+ const text = (messages: ModelMessage[]) => messages.flatMap(serializeMessage).join("\n");
6
+
7
+ describe("serializeMessage", () => {
8
+ test("user/assistant/tool 메시지 전체 포함", () => {
9
+ const messages: ModelMessage[] = [
10
+ { role: "user", content: "파일 목록 보여줘" },
11
+ {
12
+ role: "assistant",
13
+ content: [
14
+ { type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { command: "ls -la" } },
15
+ ],
16
+ },
17
+ {
18
+ role: "tool",
19
+ content: [
20
+ {
21
+ type: "tool-result",
22
+ toolCallId: "tc1",
23
+ toolName: "bash",
24
+ output: { type: "text", value: "file1.txt\nfile2.txt" },
25
+ },
26
+ ],
27
+ },
28
+ { role: "assistant", content: "파일 목록입니다." },
29
+ ];
30
+ expect(text(messages)).toBe(
31
+ "[사용자]: 파일 목록 보여줘\n" +
32
+ '[어시스턴트]: (tool: bash 호출)\n 입력: {"command":"ls -la"}\n' +
33
+ "[도구 결과 - bash]:\n file1.txt\nfile2.txt\n" +
34
+ "[어시스턴트]: 파일 목록입니다.",
35
+ );
36
+ });
37
+
38
+ test("assistant에 text와 tool-call이 섞인 경우 둘 다 포함", () => {
39
+ const messages: ModelMessage[] = [
40
+ {
41
+ role: "assistant",
42
+ content: [
43
+ { type: "text", text: "확인해보겠습니다." },
44
+ { type: "tool-call", toolCallId: "1", toolName: "bash", input: { command: "pwd" } },
45
+ ],
46
+ },
47
+ ];
48
+ expect(text(messages)).toBe(
49
+ "[어시스턴트]: 확인해보겠습니다.\n" +
50
+ '[어시스턴트]: (tool: bash 호출)\n 입력: {"command":"pwd"}',
51
+ );
52
+ });
53
+
54
+ test("tool-result output - text 타입", () => {
55
+ const messages: ModelMessage[] = [
56
+ {
57
+ role: "tool",
58
+ content: [
59
+ {
60
+ type: "tool-result",
61
+ toolCallId: "1",
62
+ toolName: "bash",
63
+ output: { type: "text", value: "stdout 결과" },
64
+ },
65
+ ],
66
+ },
67
+ ];
68
+ expect(text(messages)).toBe("[도구 결과 - bash]:\n stdout 결과");
69
+ });
70
+
71
+ test("tool-result output - json 타입", () => {
72
+ const messages: ModelMessage[] = [
73
+ {
74
+ role: "tool",
75
+ content: [
76
+ {
77
+ type: "tool-result",
78
+ toolCallId: "1",
79
+ toolName: "bash",
80
+ output: { type: "json", value: { stdout: "result", exitCode: 0 } },
81
+ },
82
+ ],
83
+ },
84
+ ];
85
+ expect(text(messages)).toBe('[도구 결과 - bash]:\n {"stdout":"result","exitCode":0}');
86
+ });
87
+
88
+ test("tool-call만 있는 assistant 메시지도 포함", () => {
89
+ const messages: ModelMessage[] = [
90
+ {
91
+ role: "assistant",
92
+ content: [{ type: "tool-call", toolCallId: "1", toolName: "bash", input: {} }],
93
+ },
94
+ { role: "user", content: "질문" },
95
+ ];
96
+ const result = text(messages);
97
+ expect(result).toContain("(tool: bash 호출)");
98
+ expect(result).toContain("[사용자]: 질문");
99
+ });
100
+
101
+ test("빈 텍스트 메시지는 제외", () => {
102
+ const messages: ModelMessage[] = [
103
+ { role: "user", content: " " },
104
+ { role: "assistant", content: "답변" },
105
+ ];
106
+ expect(text(messages)).toBe("[어시스턴트]: 답변");
107
+ });
108
+
109
+ test("메시지가 없으면 빈 문자열 반환", () => {
110
+ expect(text([])).toBe("");
111
+ });
112
+ });
@@ -0,0 +1,63 @@
1
+ import type { ModelMessage, TextPart } from "ai";
2
+ import type { MessageRow } from "@/db/conversationStore";
3
+
4
+ export type Message = ModelMessage & { createdAt: number };
5
+
6
+ export function fromRow(row: MessageRow): Message {
7
+ return { role: row.role, content: JSON.parse(row.content), createdAt: row.created_at } as Message;
8
+ }
9
+
10
+ export function toModelMessage(msg: Message): ModelMessage {
11
+ return msg;
12
+ }
13
+
14
+ function serializeToolOutput(output: unknown): string {
15
+ const o = output as { type: string; value?: unknown; reason?: string };
16
+ if (o.type === "text" || o.type === "error-text") return String(o.value ?? "");
17
+ if (o.type === "json" || o.type === "error-json") return JSON.stringify(o.value);
18
+ if (o.type === "execution-denied") return `[실행 거부]${o.reason ? ` ${o.reason}` : ""}`;
19
+ if (o.type === "content") {
20
+ const parts = o.value as Array<{ type: string; text?: string }>;
21
+ return (
22
+ parts
23
+ .filter((p) => p.type === "text")
24
+ .map((p) => p.text ?? "")
25
+ .join("") || JSON.stringify(o.value)
26
+ );
27
+ }
28
+ return JSON.stringify(output);
29
+ }
30
+
31
+ export function serializeMessage(m: ModelMessage): string[] {
32
+ if (m.role === "user") {
33
+ const text =
34
+ typeof m.content === "string"
35
+ ? m.content
36
+ : m.content
37
+ .filter((p): p is TextPart => p.type === "text")
38
+ .map((p) => p.text)
39
+ .join("");
40
+ return text.trim() ? [`[사용자]: ${text.trim()}`] : [];
41
+ }
42
+
43
+ if (m.role === "assistant") {
44
+ if (typeof m.content === "string")
45
+ return m.content.trim() ? [`[어시스턴트]: ${m.content.trim()}`] : [];
46
+ return m.content.flatMap((part) => {
47
+ if (part.type === "text")
48
+ return part.text.trim() ? [`[어시스턴트]: ${part.text.trim()}`] : [];
49
+ if (part.type === "tool-call")
50
+ return [
51
+ `[어시스턴트]: (tool: ${part.toolName} 호출)\n 입력: ${JSON.stringify(part.input)}`,
52
+ ];
53
+ return [];
54
+ });
55
+ }
56
+
57
+ if (m.role === "tool")
58
+ return m.content
59
+ .filter((p) => p.type === "tool-result")
60
+ .map((p) => `[도구 결과 - ${p.toolName}]:\n ${serializeToolOutput(p.output)}`);
61
+
62
+ return [];
63
+ }
@@ -0,0 +1,60 @@
1
+ import { join } from "node:path";
2
+ import Mustache from "mustache";
3
+ import { type MemoryData, readGlobalMemory, readMemory } from "@/memory/index";
4
+ import type { MemoryScope } from "@/memory/types";
5
+
6
+ // 프롬프트는 HTML 컨텍스트가 아니므로 HTML 이스케이프 비활성화
7
+ Mustache.escape = (text: string) => text;
8
+
9
+ function buildTemplateView(): Record<string, string> {
10
+ const now = new Date();
11
+ const formatter = new Intl.DateTimeFormat("ko-KR", {
12
+ timeZone: "Asia/Seoul",
13
+ year: "numeric",
14
+ month: "long",
15
+ day: "numeric",
16
+ weekday: "long",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ });
20
+ return { current_time_kokr: formatter.format(now), home_dir: process.env.U1Z_HOME };
21
+ }
22
+
23
+ // 파일 캐시를 두지 않고 매번 읽는다.
24
+ // current_time_kokr 등 동적 변수를 매 Conversation마다 최신 상태로 치환해야 하며,
25
+ // Conversation 자체가 1시간 디바운스로 재생성되므로 호출 빈도가 낮아 I/O 부담이 없다.
26
+ export async function getBasePrompt(): Promise<string> {
27
+ const path = join(process.env.U1Z_HOME, ".u1z", "prompt", "BASE.md");
28
+ const file = Bun.file(path);
29
+ if (!(await file.exists())) throw new Error(`Prompt file not found: ${path}`);
30
+
31
+ const text = (await file.text()).trim();
32
+ if (!text) throw new Error(`Prompt file is empty: ${path}`);
33
+
34
+ return Mustache.render(text, buildTemplateView());
35
+ }
36
+
37
+ export function buildMemoryXml(globalMemory: string, memories: MemoryData[]): string {
38
+ const parts: string[] = [];
39
+
40
+ if (globalMemory) parts.push(`<Shared Memory>\n${globalMemory}\n</Shared Memory>`);
41
+ for (const m of memories) {
42
+ parts.push(`<Memory at ${m.date}>\n${m.content}\n</Memory at ${m.date}>`);
43
+ }
44
+
45
+ if (parts.length === 0) return "";
46
+ return `<Memories>\n${parts.join("\n")}\n</Memories>`;
47
+ }
48
+
49
+ /** @param extra - guild 채널 발화자 포맷 설명 등 추가 컨텍스트 */
50
+ export async function buildSystemPrompt(scope: MemoryScope, extra?: string): Promise<string> {
51
+ const [baseMd, globalMemory, memories] = await Promise.all([
52
+ getBasePrompt(),
53
+ readGlobalMemory(),
54
+ readMemory(scope),
55
+ ]);
56
+
57
+ const memoryXml = buildMemoryXml(globalMemory, memories);
58
+
59
+ return [baseMd, memoryXml, extra].filter(Boolean).join("\n\n---\n\n");
60
+ }
@@ -0,0 +1,100 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export interface ConversationRow {
4
+ id: string;
5
+ channel_id: string;
6
+ scope: string;
7
+ model: string;
8
+ system_prompt: string;
9
+ last_message_at: number;
10
+ memorized: number;
11
+ }
12
+
13
+ export interface MessageRow {
14
+ id: number;
15
+ conversation_id: string;
16
+ role: string;
17
+ content: string;
18
+ created_at: number;
19
+ }
20
+
21
+ export interface ActiveConversationData {
22
+ conversation: ConversationRow;
23
+ messages: MessageRow[];
24
+ }
25
+
26
+ export class ConversationStore {
27
+ constructor(private db: Database) {}
28
+
29
+ insertConversation(
30
+ id: string,
31
+ channelId: string,
32
+ scope: string,
33
+ model: string,
34
+ systemPrompt: string,
35
+ lastMessageAt: number,
36
+ ): void {
37
+ this.db.run(
38
+ `INSERT INTO conversations (id, channel_id, scope, model, system_prompt, last_message_at)
39
+ VALUES (?, ?, ?, ?, ?, ?)`,
40
+ [id, channelId, scope, model, systemPrompt, lastMessageAt],
41
+ );
42
+ }
43
+
44
+ insertMessage(conversationId: string, role: string, content: string, createdAt: number): void {
45
+ this.db.run(
46
+ `INSERT INTO conversation_messages (conversation_id, role, content, created_at)
47
+ VALUES (?, ?, ?, ?)`,
48
+ [conversationId, role, content, createdAt],
49
+ );
50
+ }
51
+
52
+ touchConversation(conversationId: string, lastMessageAt: number): void {
53
+ this.db.run(`UPDATE conversations SET last_message_at = ? WHERE id = ?`, [
54
+ lastMessageAt,
55
+ conversationId,
56
+ ]);
57
+ }
58
+
59
+ loadActive(minLastMessageAt: number): ActiveConversationData[] {
60
+ const convs = this.db
61
+ .query<ConversationRow, [number]>(
62
+ `SELECT * FROM conversations WHERE memorized = 0 AND last_message_at >= ?
63
+ ORDER BY last_message_at ASC`,
64
+ )
65
+ .all(minLastMessageAt);
66
+
67
+ return convs.map((conv) => ({
68
+ conversation: conv,
69
+ messages: this.db
70
+ .query<MessageRow, [string]>(
71
+ `SELECT * FROM conversation_messages WHERE conversation_id = ?
72
+ ORDER BY id ASC`,
73
+ )
74
+ .all(conv.id),
75
+ }));
76
+ }
77
+
78
+ loadPendingMemorize(maxLastMessageAt: number): ActiveConversationData[] {
79
+ const convs = this.db
80
+ .query<ConversationRow, [number]>(
81
+ `SELECT * FROM conversations WHERE memorized = 0 AND last_message_at < ?
82
+ ORDER BY last_message_at ASC`,
83
+ )
84
+ .all(maxLastMessageAt);
85
+
86
+ return convs.map((conv) => ({
87
+ conversation: conv,
88
+ messages: this.db
89
+ .query<MessageRow, [string]>(
90
+ `SELECT * FROM conversation_messages WHERE conversation_id = ?
91
+ ORDER BY id ASC`,
92
+ )
93
+ .all(conv.id),
94
+ }));
95
+ }
96
+
97
+ markMemorized(conversationId: string): void {
98
+ this.db.run(`UPDATE conversations SET memorized = 1 WHERE id = ?`, [conversationId]);
99
+ }
100
+ }
@@ -0,0 +1,21 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { runMigrations as _runMigrations } from "./migrator";
5
+
6
+ let _db: Database | undefined;
7
+
8
+ export function getDb(): Database {
9
+ if (!_db) {
10
+ const dir = join(process.env.U1Z_HOME, ".u1z");
11
+ mkdirSync(dir, { recursive: true });
12
+ _db = new Database(join(dir, "u1z.db"), { create: true });
13
+ _db.run("PRAGMA journal_mode = WAL;");
14
+ }
15
+ return _db;
16
+ }
17
+
18
+ export function runMigrations(): void {
19
+ const migrationsDir = join(import.meta.dir, "..", "..", "migrations");
20
+ _runMigrations(getDb(), { migrationsDir });
21
+ }
@@ -0,0 +1,129 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { afterEach, describe, expect, test } from "bun:test";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { runMigrations } from "./migrator";
7
+
8
+ const tempDirs: string[] = [];
9
+ const openedDbs: Database[] = [];
10
+
11
+ afterEach(() => {
12
+ while (openedDbs.length > 0) {
13
+ const db = openedDbs.pop();
14
+ db?.close();
15
+ }
16
+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
17
+ rmSync(dir, { force: true, recursive: true });
18
+ }
19
+ });
20
+
21
+ function createTempDir(prefix: string): string {
22
+ const dir = mkdtempSync(join(tmpdir(), prefix));
23
+ tempDirs.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ function createDb(): Database {
28
+ const dir = createTempDir("u1z-db-migrator-db-");
29
+ const db = new Database(join(dir, "app.sqlite3"), { create: true });
30
+ openedDbs.push(db);
31
+ return db;
32
+ }
33
+
34
+ function createMigrationsDir(files: Record<string, string>): string {
35
+ const dir = createTempDir("u1z-db-migrator-sql-");
36
+ mkdirSync(dir, { recursive: true });
37
+ for (const [filename, sql] of Object.entries(files)) {
38
+ writeFileSync(join(dir, filename), sql, "utf8");
39
+ }
40
+ return dir;
41
+ }
42
+
43
+ type NameRow = {
44
+ name: string;
45
+ };
46
+
47
+ type VersionRow = {
48
+ version: number;
49
+ };
50
+
51
+ describe("runMigrations", () => {
52
+ test("applies sql files in ascending version order", () => {
53
+ const db = createDb();
54
+ const migrationsDir = createMigrationsDir({
55
+ "0_init.sql": "CREATE TABLE trace (name TEXT NOT NULL);",
56
+ "1_insert_v1.sql": "INSERT INTO trace (name) VALUES ('v1');",
57
+ "2_insert_v2.sql": "INSERT INTO trace (name) VALUES ('v2');",
58
+ });
59
+
60
+ runMigrations(db, { migrationsDir });
61
+
62
+ const appliedVersions = db
63
+ .query<VersionRow, []>("SELECT version FROM schema_migrations ORDER BY version ASC")
64
+ .all()
65
+ .map((row) => row.version);
66
+ const traceNames = db
67
+ .query<NameRow, []>("SELECT name FROM trace ORDER BY rowid ASC")
68
+ .all()
69
+ .map((row) => row.name);
70
+
71
+ expect(appliedVersions).toEqual([0, 1, 2]);
72
+ expect(traceNames).toEqual(["v1", "v2"]);
73
+ });
74
+
75
+ test("applies only missing versions when new sql file is added", () => {
76
+ const db = createDb();
77
+ const migrationsDir = createMigrationsDir({
78
+ "0_init.sql": "CREATE TABLE trace (name TEXT NOT NULL);",
79
+ "1_insert_v1.sql": "INSERT INTO trace (name) VALUES ('v1');",
80
+ });
81
+
82
+ runMigrations(db, { migrationsDir });
83
+ writeFileSync(
84
+ join(migrationsDir, "2_insert_v2.sql"),
85
+ "INSERT INTO trace (name) VALUES ('v2');",
86
+ "utf8",
87
+ );
88
+ runMigrations(db, { migrationsDir });
89
+
90
+ const traceNames = db
91
+ .query<NameRow, []>("SELECT name FROM trace ORDER BY rowid ASC")
92
+ .all()
93
+ .map((row) => row.name);
94
+
95
+ expect(traceNames).toEqual(["v1", "v2"]);
96
+ });
97
+
98
+ test("throws when database schema version is newer than local sql files", () => {
99
+ const db = createDb();
100
+ const migrationsDir = createMigrationsDir({
101
+ "0_init.sql": "CREATE TABLE trace (name TEXT NOT NULL);",
102
+ });
103
+
104
+ runMigrations(db, { migrationsDir });
105
+ db.run("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", [
106
+ 99,
107
+ "manual_future_version",
108
+ ]);
109
+
110
+ expect(() => runMigrations(db, { migrationsDir })).toThrow(
111
+ "Database schema version 99 is newer than supported version 0",
112
+ );
113
+ });
114
+
115
+ test("supports custom migrations table name", () => {
116
+ const db = createDb();
117
+ const migrationsDir = createMigrationsDir({
118
+ "0_init.sql": "CREATE TABLE trace (name TEXT NOT NULL);",
119
+ });
120
+
121
+ runMigrations(db, { migrationsDir, migrationsTableName: "custom_migrations" });
122
+
123
+ const appliedVersions = db
124
+ .query<VersionRow, []>("SELECT version FROM custom_migrations ORDER BY version ASC")
125
+ .all()
126
+ .map((row) => row.version);
127
+ expect(appliedVersions).toEqual([0]);
128
+ });
129
+ });
@@ -0,0 +1,120 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const MIGRATION_FILE_PATTERN = /^(\d+)_([a-zA-Z0-9_-]+)\.sql$/;
6
+ const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
7
+
8
+ type AppliedMigrationRow = {
9
+ version: number;
10
+ };
11
+
12
+ type MaxVersionRow = {
13
+ maxVersion: number | null;
14
+ };
15
+
16
+ export type SqlMigration = {
17
+ filename: string;
18
+ name: string;
19
+ sql: string;
20
+ version: number;
21
+ };
22
+
23
+ export type MigratorOptions = {
24
+ migrationsDir: string;
25
+ migrationsTableName?: string;
26
+ };
27
+
28
+ function resolveMigrationsTableName(tableName?: string): string {
29
+ const resolved = tableName ?? "schema_migrations";
30
+ if (!TABLE_NAME_PATTERN.test(resolved))
31
+ throw new Error(`Invalid migrations table name: ${resolved}`);
32
+ return resolved;
33
+ }
34
+
35
+ function loadSqlMigrations(migrationsDir: string): SqlMigration[] {
36
+ const entries = readdirSync(migrationsDir, { withFileTypes: true });
37
+ const migrations: SqlMigration[] = [];
38
+ const seenVersions = new Set<number>();
39
+
40
+ for (const entry of entries) {
41
+ if (!entry.isFile()) continue;
42
+
43
+ const match = entry.name.match(MIGRATION_FILE_PATTERN);
44
+ if (!match) continue;
45
+
46
+ const version = Number(match[1]);
47
+ const name = match[2];
48
+ if (!Number.isInteger(version) || version < 0)
49
+ throw new Error(`Invalid migration version in filename: ${entry.name}`);
50
+ if (seenVersions.has(version)) throw new Error(`Duplicate migration version: ${version}`);
51
+ seenVersions.add(version);
52
+
53
+ const path = join(migrationsDir, entry.name);
54
+ const sql = readFileSync(path, "utf8").trim();
55
+ if (!sql) throw new Error(`Migration file is empty: ${entry.name}`);
56
+
57
+ migrations.push({
58
+ version,
59
+ name,
60
+ filename: entry.name,
61
+ sql,
62
+ });
63
+ }
64
+
65
+ return migrations.sort((a, b) => a.version - b.version);
66
+ }
67
+
68
+ export function runMigrations(db: Database, options: MigratorOptions): void {
69
+ const migrationsTableName = resolveMigrationsTableName(options.migrationsTableName);
70
+ const migrations = loadSqlMigrations(options.migrationsDir);
71
+
72
+ db.run(`
73
+ CREATE TABLE IF NOT EXISTS ${migrationsTableName} (
74
+ version INTEGER PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
77
+ );
78
+ `);
79
+
80
+ const latestKnownVersion = migrations.at(-1)?.version ?? -1;
81
+ const maxVersionRow = db
82
+ .query<MaxVersionRow, []>(`SELECT MAX(version) AS maxVersion FROM ${migrationsTableName}`)
83
+ .get();
84
+ const maxAppliedVersion = maxVersionRow?.maxVersion ?? null;
85
+
86
+ if (maxAppliedVersion !== null && maxAppliedVersion > latestKnownVersion)
87
+ throw new Error(
88
+ `Database schema version ${maxAppliedVersion} is newer than supported version ${latestKnownVersion}`,
89
+ );
90
+
91
+ const appliedRows = db
92
+ .query<AppliedMigrationRow, []>(
93
+ `SELECT version FROM ${migrationsTableName} ORDER BY version ASC`,
94
+ )
95
+ .all();
96
+ const appliedVersions = new Set(appliedRows.map((row) => row.version));
97
+
98
+ const recordAppliedStatement = db.query<unknown, [number, string]>(`
99
+ INSERT INTO ${migrationsTableName} (version, name)
100
+ VALUES (?, ?)
101
+ `);
102
+
103
+ for (const migration of migrations) {
104
+ if (appliedVersions.has(migration.version)) continue;
105
+
106
+ db.run("BEGIN IMMEDIATE");
107
+ try {
108
+ db.run(migration.sql);
109
+ recordAppliedStatement.run(migration.version, migration.name);
110
+ db.run("COMMIT");
111
+ appliedVersions.add(migration.version);
112
+ } catch (error: unknown) {
113
+ db.run("ROLLBACK");
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ throw new Error(
116
+ `Failed to apply migration ${migration.filename} (v${migration.version}): ${message}`,
117
+ );
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,11 @@
1
+ import { Client, GatewayIntentBits, Partials } from "discord.js";
2
+
3
+ export const client = new Client({
4
+ intents: [
5
+ GatewayIntentBits.Guilds,
6
+ GatewayIntentBits.GuildMessages,
7
+ GatewayIntentBits.MessageContent,
8
+ GatewayIntentBits.DirectMessages,
9
+ ],
10
+ partials: [Partials.Channel],
11
+ });
@@ -0,0 +1,69 @@
1
+ import {
2
+ AttachmentBuilder,
3
+ type ChatInputCommandInteraction,
4
+ type Interaction,
5
+ MessageFlags,
6
+ } from "discord.js";
7
+ import { conversationManager } from "@/conversation/manager";
8
+
9
+ export const SLASH_COMMANDS = [
10
+ { name: "usage", description: "현재 대화 사용량 조회" },
11
+ { name: "new", description: "새 대화 시작" },
12
+ { name: "sysprompt", description: "현재 시스템 프롬프트 출력" },
13
+ ] as const;
14
+
15
+ export async function handleInteractionCreate(interaction: Interaction): Promise<void> {
16
+ if (!interaction.isChatInputCommand()) return;
17
+
18
+ switch (interaction.commandName) {
19
+ case "usage":
20
+ handleUsageCommand(interaction);
21
+ break;
22
+ case "new":
23
+ await handleNewCommand(interaction);
24
+ break;
25
+ case "sysprompt":
26
+ handleSyspromptCommand(interaction);
27
+ break;
28
+ default:
29
+ interaction.reply({ content: "알 수 없는 명령어예요.", flags: MessageFlags.Ephemeral });
30
+ }
31
+ }
32
+
33
+ function handleUsageCommand(interaction: ChatInputCommandInteraction): void {
34
+ const conv = conversationManager.get(interaction.channelId);
35
+ if (!conv) {
36
+ interaction.reply({ content: "아직 대화가 없어요.", flags: MessageFlags.Ephemeral });
37
+ return;
38
+ }
39
+ interaction.reply({ content: conv.getDebugInfo(), flags: MessageFlags.Ephemeral });
40
+ }
41
+
42
+ async function handleNewCommand(interaction: ChatInputCommandInteraction): Promise<void> {
43
+ await interaction.deferReply();
44
+ const conv = conversationManager.get(interaction.channelId);
45
+ if (conv)
46
+ try {
47
+ await conv.memorize();
48
+ } catch (err) {
49
+ console.error(
50
+ "[memorize] /new 중 요약 실패:",
51
+ err instanceof Error ? err.message : String(err),
52
+ );
53
+ }
54
+ conversationManager.reset(interaction.channelId);
55
+ await interaction.editReply("새로운 대화를 시작했어요.");
56
+ }
57
+
58
+ async function handleSyspromptCommand(interaction: ChatInputCommandInteraction): Promise<void> {
59
+ const conv = conversationManager.get(interaction.channelId);
60
+ if (!conv) {
61
+ await interaction.reply({ content: "아직 대화가 없어요.", flags: MessageFlags.Ephemeral });
62
+ return;
63
+ }
64
+
65
+ const file = new AttachmentBuilder(Buffer.from(conv.systemPrompt, "utf-8"), {
66
+ name: "system-prompt.md",
67
+ });
68
+ await interaction.reply({ files: [file], flags: MessageFlags.Ephemeral });
69
+ }