@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.
- package/README.md +284 -0
- package/migrations/001_conversations.sql +23 -0
- package/package.json +50 -0
- package/src/ai/llm/model.ts +15 -0
- package/src/ai/llm/tools/analyzeYoutube.ts +227 -0
- package/src/ai/llm/tools/bash.test.ts +24 -0
- package/src/ai/llm/tools/bash.ts +20 -0
- package/src/ai/llm/tools/tavilyClient.ts +3 -0
- package/src/ai/llm/tools/textEditor.test.ts +91 -0
- package/src/ai/llm/tools/textEditor.ts +87 -0
- package/src/ai/llm/tools/webFetch.ts +41 -0
- package/src/ai/llm/tools/webSearch.ts +84 -0
- package/src/cli/commands/doctor.ts +138 -0
- package/src/cli/commands/init.ts +130 -0
- package/src/cli/commands/logs.ts +11 -0
- package/src/cli/commands/server.ts +28 -0
- package/src/cli/commands/status.ts +8 -0
- package/src/cli/commands/update.ts +21 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/utils/color.ts +7 -0
- package/src/cli/utils/prompt.ts +16 -0
- package/src/conversation/basePrompt.test.ts +43 -0
- package/src/conversation/conversation.test.ts +197 -0
- package/src/conversation/conversation.ts +156 -0
- package/src/conversation/manager.test.ts +108 -0
- package/src/conversation/manager.ts +72 -0
- package/src/conversation/messages.test.ts +112 -0
- package/src/conversation/messages.ts +63 -0
- package/src/conversation/systemPrompt.ts +60 -0
- package/src/db/conversationStore.ts +100 -0
- package/src/db/index.ts +21 -0
- package/src/db/migrator.test.ts +129 -0
- package/src/db/migrator.ts +120 -0
- package/src/discord/client.ts +11 -0
- package/src/discord/handlers/interactionCreate.ts +69 -0
- package/src/discord/handlers/messageCreate.test.ts +49 -0
- package/src/discord/handlers/messageCreate.ts +180 -0
- package/src/discord/index.ts +49 -0
- package/src/discord/systemPrompt.test.ts +30 -0
- package/src/env.d.ts +28 -0
- package/src/memory/compress.ts +102 -0
- package/src/memory/index.test.ts +84 -0
- package/src/memory/index.ts +103 -0
- package/src/memory/memorize.ts +38 -0
- package/src/memory/types.ts +1 -0
- package/tsconfig.json +24 -0
- package/u1z_home_bootstrap/.u1z/prompt/BASE.md +41 -0
- package/u1z_home_bootstrap/.u1z/prompt/DREAM.md +12 -0
- package/u1z_home_bootstrap/.u1z/prompt/MEMORIZE.md +11 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { splitMessage } from "./messageCreate";
|
|
3
|
+
|
|
4
|
+
describe("splitMessage", () => {
|
|
5
|
+
test("2000자 이하면 그대로 반환", () => {
|
|
6
|
+
const text = "a".repeat(2000);
|
|
7
|
+
expect(splitMessage(text)).toEqual([text]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("2001자면 2개로 분할", () => {
|
|
11
|
+
const text = "a".repeat(2001);
|
|
12
|
+
const chunks = splitMessage(text);
|
|
13
|
+
expect(chunks).toHaveLength(2);
|
|
14
|
+
expect(chunks[0]).toHaveLength(2000);
|
|
15
|
+
expect(chunks[1]).toHaveLength(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("newline 경계에서 분할", () => {
|
|
19
|
+
const line = "a".repeat(1999);
|
|
20
|
+
const text = `${line}\n${"b".repeat(10)}`;
|
|
21
|
+
const chunks = splitMessage(text);
|
|
22
|
+
expect(chunks).toHaveLength(2);
|
|
23
|
+
expect(chunks[0]).toBe(line);
|
|
24
|
+
expect(chunks[1]).toBe("b".repeat(10));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("newline이 없으면 2000자에서 강제 컷", () => {
|
|
28
|
+
const text = "a".repeat(3000);
|
|
29
|
+
const chunks = splitMessage(text);
|
|
30
|
+
expect(chunks).toHaveLength(2);
|
|
31
|
+
expect(chunks[0]).toHaveLength(2000);
|
|
32
|
+
expect(chunks[1]).toHaveLength(1000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("여러 청크로 분할", () => {
|
|
36
|
+
const text = "a".repeat(5000);
|
|
37
|
+
const chunks = splitMessage(text);
|
|
38
|
+
for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
39
|
+
expect(chunks.join("")).toBe(text);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("newline이 포함된 여러 청크", () => {
|
|
43
|
+
const line = "a".repeat(500);
|
|
44
|
+
const text = Array.from({ length: 10 }, () => line).join("\n");
|
|
45
|
+
const chunks = splitMessage(text);
|
|
46
|
+
for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
47
|
+
expect(chunks.join("\n")).toBe(text);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { APICallError, generateText, type ModelMessage, stepCountIs } from "ai";
|
|
2
|
+
import type { Message } from "discord.js";
|
|
3
|
+
import { MODELS } from "@/ai/llm/model";
|
|
4
|
+
import { analyzeYoutube } from "@/ai/llm/tools/analyzeYoutube";
|
|
5
|
+
import { bash } from "@/ai/llm/tools/bash";
|
|
6
|
+
import { textEditor } from "@/ai/llm/tools/textEditor";
|
|
7
|
+
import { webFetch } from "@/ai/llm/tools/webFetch";
|
|
8
|
+
import { webSearch } from "@/ai/llm/tools/webSearch";
|
|
9
|
+
import type { Usage } from "@/conversation/conversation";
|
|
10
|
+
import { conversationManager } from "@/conversation/manager";
|
|
11
|
+
|
|
12
|
+
import { compactMemories } from "@/memory/compress";
|
|
13
|
+
import type { MemoryScope } from "@/memory/types";
|
|
14
|
+
|
|
15
|
+
const DISCORD_MAX_LENGTH = 2000;
|
|
16
|
+
const IS_E2E = process.env.IS_E2E === "true";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 디스코는 메세지당 최대 2000자까지만 허용하므로, 이를 초과하는 경우 여러 메세지로 나누어 전송한다.
|
|
20
|
+
*/
|
|
21
|
+
export function splitMessage(text: string): string[] {
|
|
22
|
+
if (text.length <= DISCORD_MAX_LENGTH) return [text];
|
|
23
|
+
const chunks: string[] = [];
|
|
24
|
+
let remaining = text;
|
|
25
|
+
while (remaining.length > DISCORD_MAX_LENGTH) {
|
|
26
|
+
const slice = remaining.slice(0, DISCORD_MAX_LENGTH);
|
|
27
|
+
const lastNewline = slice.lastIndexOf("\n");
|
|
28
|
+
const cut = lastNewline > 0 ? lastNewline : DISCORD_MAX_LENGTH;
|
|
29
|
+
chunks.push(remaining.slice(0, cut));
|
|
30
|
+
remaining = remaining.slice(lastNewline > 0 ? lastNewline + 1 : DISCORD_MAX_LENGTH);
|
|
31
|
+
}
|
|
32
|
+
if (remaining) chunks.push(remaining);
|
|
33
|
+
return chunks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ALLOWED_BOT_IDS = (process.env.DISCORD_ALLOWED_BOT_IDS ?? "")
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((id) => id.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
|
|
41
|
+
function toModelMessage(message: Message, botId: string, isDM: boolean): ModelMessage | null {
|
|
42
|
+
const content = isDM
|
|
43
|
+
? message.content
|
|
44
|
+
: message.content.replace(new RegExp(`^<@!?${botId}>\\s*`), "").trim();
|
|
45
|
+
|
|
46
|
+
if (!content) return null;
|
|
47
|
+
|
|
48
|
+
if (message.author.id === botId) return { role: "assistant", content };
|
|
49
|
+
|
|
50
|
+
if (message.author.bot && !ALLOWED_BOT_IDS.includes(message.author.id)) return null;
|
|
51
|
+
|
|
52
|
+
if (isDM) return { role: "user", content };
|
|
53
|
+
|
|
54
|
+
const displayName =
|
|
55
|
+
message.member?.displayName ?? message.author.displayName ?? message.author.username;
|
|
56
|
+
return { role: "user", content: `[${displayName}]: ${content}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function handleMessageCreate(message: Message): Promise<void> {
|
|
60
|
+
if (message.system) return;
|
|
61
|
+
|
|
62
|
+
const botId = message.client.user?.id;
|
|
63
|
+
if (!botId) {
|
|
64
|
+
console.error("[Discord] Client user is not ready");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (message.author.id === botId) return;
|
|
69
|
+
|
|
70
|
+
if (message.author.bot && !ALLOWED_BOT_IDS.includes(message.author.id)) return;
|
|
71
|
+
|
|
72
|
+
const isDM = message.channel.isDMBased();
|
|
73
|
+
if (!isDM && !message.mentions.has(botId)) return;
|
|
74
|
+
|
|
75
|
+
if (!message.content.trim()) return;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
if ("sendTyping" in message.channel) await message.channel.sendTyping();
|
|
79
|
+
|
|
80
|
+
const scope: MemoryScope = isDM
|
|
81
|
+
? { kind: "user", userId: message.author.id }
|
|
82
|
+
: { kind: "channel", channelId: message.channelId };
|
|
83
|
+
|
|
84
|
+
// 만료된 Conversation이 있으면 먼저 요약 저장 후 새 대화 시작
|
|
85
|
+
const expiredConversation = conversationManager.getIfExpired(message.channelId);
|
|
86
|
+
if (expiredConversation) {
|
|
87
|
+
const [memorizeResult, compactResult] = await Promise.allSettled([
|
|
88
|
+
expiredConversation.memorize(),
|
|
89
|
+
compactMemories(expiredConversation.scope),
|
|
90
|
+
]);
|
|
91
|
+
if (memorizeResult.status === "rejected")
|
|
92
|
+
console.error(
|
|
93
|
+
"[Memorize] 요약 실패:",
|
|
94
|
+
memorizeResult.reason instanceof Error
|
|
95
|
+
? memorizeResult.reason.message
|
|
96
|
+
: String(memorizeResult.reason),
|
|
97
|
+
);
|
|
98
|
+
if (compactResult.status === "rejected")
|
|
99
|
+
console.error(
|
|
100
|
+
"[Compress] 압축 실패:",
|
|
101
|
+
compactResult.reason instanceof Error
|
|
102
|
+
? compactResult.reason.message
|
|
103
|
+
: String(compactResult.reason),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { conversation } = await conversationManager.getOrCreate(
|
|
108
|
+
message.channelId,
|
|
109
|
+
MODELS.md,
|
|
110
|
+
scope,
|
|
111
|
+
!isDM
|
|
112
|
+
? "# Important Notes\n사용자 메시지는 `[표시 이름]: 내용` 형식으로 전달됩니다. 대괄호 안은 메시지를 보낸 Discord 사용자의 표시 이름입니다."
|
|
113
|
+
: undefined,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const userMessage = toModelMessage(message, botId, isDM);
|
|
117
|
+
if (!userMessage) return;
|
|
118
|
+
conversation.addMessage(userMessage);
|
|
119
|
+
conversation.touch();
|
|
120
|
+
|
|
121
|
+
const { text, usage, response } = await generateText({
|
|
122
|
+
model: conversation.model,
|
|
123
|
+
system: conversation.systemPrompt,
|
|
124
|
+
messages: conversation.getMessages(),
|
|
125
|
+
maxOutputTokens: 1024,
|
|
126
|
+
timeout: 300_000,
|
|
127
|
+
tools: {
|
|
128
|
+
bash,
|
|
129
|
+
text_editor: textEditor,
|
|
130
|
+
web_search: webSearch,
|
|
131
|
+
web_fetch: webFetch,
|
|
132
|
+
analyze_youtube: analyzeYoutube,
|
|
133
|
+
},
|
|
134
|
+
stopWhen: stepCountIs(20),
|
|
135
|
+
onStepFinish: async (step) => {
|
|
136
|
+
if (step.finishReason === "stop" || IS_E2E) return;
|
|
137
|
+
const ch = message.channel;
|
|
138
|
+
if (ch && step.text)
|
|
139
|
+
for (const chunk of splitMessage(step.text)) await message.reply(chunk);
|
|
140
|
+
if (ch && "sendTyping" in ch) await ch.sendTyping();
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const normalizedUsage: Usage = {
|
|
145
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
146
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
147
|
+
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
|
|
148
|
+
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
|
|
149
|
+
};
|
|
150
|
+
conversation.accumulateUsage(normalizedUsage);
|
|
151
|
+
conversation.touch();
|
|
152
|
+
for (const msg of response.messages) {
|
|
153
|
+
conversation.addMessage(msg as ModelMessage);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (text) {
|
|
157
|
+
const chunks = IS_E2E ? [text] : splitMessage(text);
|
|
158
|
+
for (const chunk of chunks) await message.reply(chunk);
|
|
159
|
+
}
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
162
|
+
console.error(`[Discord] LLM reply failed: ${errorMessage}`);
|
|
163
|
+
|
|
164
|
+
const isTimeout =
|
|
165
|
+
error instanceof Error &&
|
|
166
|
+
(error.name === "AbortError" ||
|
|
167
|
+
error.message.includes("timed out") ||
|
|
168
|
+
error.message.includes("operation timed out"));
|
|
169
|
+
if (isTimeout) {
|
|
170
|
+
await message.reply("응답이 지연되고 있어요. 잠시 후 다시 시도해 주세요.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (APICallError.isInstance(error) && error.statusCode === 429) {
|
|
174
|
+
await message.reply("이용 한도가 있어서 잠시 후 다시 말걸어 주세요.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await message.reply("일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요.");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Conversation } from "@/conversation/conversation";
|
|
2
|
+
import { conversationManager } from "@/conversation/manager";
|
|
3
|
+
import { ConversationStore } from "@/db/conversationStore";
|
|
4
|
+
import { getDb, runMigrations } from "@/db/index";
|
|
5
|
+
import { client } from "./client";
|
|
6
|
+
import { handleInteractionCreate, SLASH_COMMANDS } from "./handlers/interactionCreate";
|
|
7
|
+
import { handleMessageCreate } from "./handlers/messageCreate";
|
|
8
|
+
|
|
9
|
+
client.once("clientReady", async (c) => {
|
|
10
|
+
console.log(`[Discord] Bot ready: ${c.user.tag}`);
|
|
11
|
+
await c.application.commands.set(SLASH_COMMANDS);
|
|
12
|
+
console.log("[Discord] Slash commands registered");
|
|
13
|
+
});
|
|
14
|
+
client.on("messageCreate", handleMessageCreate);
|
|
15
|
+
client.on("interactionCreate", handleInteractionCreate);
|
|
16
|
+
|
|
17
|
+
export async function startDiscord(): Promise<void> {
|
|
18
|
+
// DB 초기화
|
|
19
|
+
runMigrations();
|
|
20
|
+
const store = new ConversationStore(getDb());
|
|
21
|
+
conversationManager.init(store);
|
|
22
|
+
|
|
23
|
+
const threshold = Date.now() - Conversation.TTL_MS;
|
|
24
|
+
|
|
25
|
+
// 1. 만료된 미완료 conversations 메모리 재시도
|
|
26
|
+
const pending = store.loadPendingMemorize(threshold);
|
|
27
|
+
if (pending.length > 0) console.log(`[Startup] Pending memorize 재시도: ${pending.length}건`);
|
|
28
|
+
for (const { conversation: row, messages } of pending) {
|
|
29
|
+
try {
|
|
30
|
+
await Conversation.fromRow(row, messages, store).memorize();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error("[Startup] Memorize 재시도 실패:", row.id, err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. 활성 conversations 복구
|
|
37
|
+
const activeData = store.loadActive(threshold);
|
|
38
|
+
console.log(`[Startup] Active conversations 복구: ${activeData.length}건`);
|
|
39
|
+
conversationManager.recoverFrom(activeData);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await client.login(process.env.DISCORD_BOT_TOKEN);
|
|
43
|
+
} catch (error: unknown) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`Failed to login Discord bot: ${message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { client };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildMemoryXml } from "@/conversation/systemPrompt";
|
|
3
|
+
|
|
4
|
+
describe("buildMemoryXml", () => {
|
|
5
|
+
test("모두 비어있으면 빈 문자열 반환", () => {
|
|
6
|
+
expect(buildMemoryXml("", [])).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("globalMemory만 있을 때", () => {
|
|
10
|
+
const result = buildMemoryXml("전역 기억", []);
|
|
11
|
+
expect(result).toBe("<Memories>\n<Shared Memory>\n전역 기억\n</Shared Memory>\n</Memories>");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("memories만 있을 때", () => {
|
|
15
|
+
const result = buildMemoryXml("", [{ date: "260226", content: "기억" }]);
|
|
16
|
+
expect(result).toBe("<Memories>\n<Memory at 260226>\n기억\n</Memory at 260226>\n</Memories>");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("전체 조합 — 순서 확인", () => {
|
|
20
|
+
const result = buildMemoryXml("전역", [
|
|
21
|
+
{ date: "260225", content: "이틀 전" },
|
|
22
|
+
{ date: "260226", content: "어제" },
|
|
23
|
+
]);
|
|
24
|
+
expect(result).toContain("<Shared Memory>");
|
|
25
|
+
expect(result).toContain("<Memory at 260225>");
|
|
26
|
+
expect(result).toContain("<Memory at 260226>");
|
|
27
|
+
expect(result.indexOf("<Shared Memory>")).toBeLessThan(result.indexOf("<Memory at 260225>"));
|
|
28
|
+
expect(result.indexOf("<Memory at 260225>")).toBeLessThan(result.indexOf("<Memory at 260226>"));
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {};
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
namespace NodeJS {
|
|
5
|
+
interface ProcessEnv {
|
|
6
|
+
/** OpenAI API key used by the default LLM model (gpt-5.2). */
|
|
7
|
+
OPENAI_API_KEY: string;
|
|
8
|
+
/** Google Generative AI API key used by the lightweight model (Gemini3 Flash). */
|
|
9
|
+
GOOGLE_GENERATIVE_AI_API_KEY: string;
|
|
10
|
+
/** Comma-separated bot user IDs that are allowed as history/context inputs. */
|
|
11
|
+
DISCORD_ALLOWED_BOT_IDS?: string;
|
|
12
|
+
/** Discord bot token used to login via discord.js. */
|
|
13
|
+
DISCORD_BOT_TOKEN: string;
|
|
14
|
+
/** Conversation TTL in milliseconds. Defaults to 3600000 (1 hour). Useful for e2e tests. */
|
|
15
|
+
CONVERSATION_TTL_MS?: `${number}`;
|
|
16
|
+
/** Memory compression threshold in KB. Defaults to 20. Useful for e2e tests. */
|
|
17
|
+
MEMORY_COMPRESS_THRESHOLD_KB?: `${number}`;
|
|
18
|
+
/** HTTP server port as a numeric string. */
|
|
19
|
+
PORT?: `${number}`;
|
|
20
|
+
/** Home directory for u1z runtime. */
|
|
21
|
+
U1Z_HOME: string;
|
|
22
|
+
/** Tavily API key for web_search and web_fetch tools. */
|
|
23
|
+
TAVILY_API_KEY: string;
|
|
24
|
+
/** Set to "true" in e2e test environments to suppress intermediate step replies. */
|
|
25
|
+
IS_E2E?: string;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { generateText } from "ai";
|
|
4
|
+
import mustache from "mustache";
|
|
5
|
+
import { MODELS } from "@/ai/llm/model";
|
|
6
|
+
import { listMemoryFiles, resolveScopeDir } from "@/memory/index";
|
|
7
|
+
import type { MemoryScope } from "@/memory/types";
|
|
8
|
+
|
|
9
|
+
/** YYMMDD → "YYYY년 MM월 DD일" */
|
|
10
|
+
function yymmddToKorean(yymmdd: string): string {
|
|
11
|
+
// 첫 6자리만 사용 (range 파일 start date)
|
|
12
|
+
const yy = yymmdd.slice(0, 2);
|
|
13
|
+
const mm = yymmdd.slice(2, 4);
|
|
14
|
+
const dd = yymmdd.slice(4, 6);
|
|
15
|
+
const year = Number(yy) + 2000;
|
|
16
|
+
return `${year}년 ${mm}월 ${dd}일`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 파일명에서 start date(YYMMDD) 추출 — range 파일이면 ~이전 부분 */
|
|
20
|
+
function startDate(fileName: string): string {
|
|
21
|
+
return fileName.replace(".txt", "").split("~")[0];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 파일명에서 end date(YYMMDD) 추출 — range 파일이면 ~이후 부분, 단순 파일이면 그대로 */
|
|
25
|
+
function endDate(fileName: string): string {
|
|
26
|
+
const base = fileName.replace(".txt", "");
|
|
27
|
+
const parts = base.split("~");
|
|
28
|
+
return parts[parts.length - 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 해당 scope의 메모리 파일 총량이 임계값을 초과하면
|
|
33
|
+
* 오래된 순으로 50% 분량을 LLM으로 압축하여 range 파일로 저장한다.
|
|
34
|
+
*/
|
|
35
|
+
export async function compactMemories(scope: MemoryScope): Promise<void> {
|
|
36
|
+
const thresholdKb = Number(process.env.MEMORY_COMPRESS_THRESHOLD_KB ?? 20);
|
|
37
|
+
const thresholdBytes = thresholdKb * 1024;
|
|
38
|
+
|
|
39
|
+
const files = await listMemoryFiles(scope);
|
|
40
|
+
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
41
|
+
|
|
42
|
+
if (totalBytes <= thresholdBytes) return;
|
|
43
|
+
|
|
44
|
+
// 오래된 순으로 총량의 50%를 초과하는 시점까지 압축 대상 수집
|
|
45
|
+
const half = totalBytes / 2;
|
|
46
|
+
let accumulated = 0;
|
|
47
|
+
const targets: typeof files = [];
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
targets.push(f);
|
|
50
|
+
accumulated += f.size;
|
|
51
|
+
if (accumulated > half) break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (targets.length === 0) return;
|
|
55
|
+
|
|
56
|
+
const dreamPath = join(process.env.U1Z_HOME, ".u1z", "prompt", "DREAM.md");
|
|
57
|
+
const dreamFile = Bun.file(dreamPath);
|
|
58
|
+
if (!(await dreamFile.exists())) return;
|
|
59
|
+
const rawPrompt = (await dreamFile.text()).trim();
|
|
60
|
+
if (!rawPrompt) return;
|
|
61
|
+
|
|
62
|
+
const dir = resolveScopeDir(scope);
|
|
63
|
+
|
|
64
|
+
// 압축 대상 파일 내용 합치기
|
|
65
|
+
const contents = await Promise.all(
|
|
66
|
+
targets.map(async (f) => Bun.file(join(dir, f.fileName)).text()),
|
|
67
|
+
);
|
|
68
|
+
const combinedContent = contents.join("\n\n");
|
|
69
|
+
|
|
70
|
+
// range 파일명 계산
|
|
71
|
+
const rangeStart = startDate(targets[0].fileName);
|
|
72
|
+
const rangeEnd = endDate(targets[targets.length - 1].fileName);
|
|
73
|
+
const rangeFileName =
|
|
74
|
+
rangeStart === rangeEnd ? `${rangeStart}.txt` : `${rangeStart}~${rangeEnd}.txt`;
|
|
75
|
+
|
|
76
|
+
// mustache 치환
|
|
77
|
+
const renderedPrompt = mustache.render(rawPrompt, {
|
|
78
|
+
memory_start_kokr: yymmddToKorean(rangeStart),
|
|
79
|
+
memory_end_kokr: yymmddToKorean(rangeEnd),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const { text } = await generateText({
|
|
83
|
+
model: MODELS.sm,
|
|
84
|
+
system: renderedPrompt,
|
|
85
|
+
messages: [{ role: "user", content: combinedContent }],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!text.trim()) return;
|
|
89
|
+
|
|
90
|
+
// range 파일 저장
|
|
91
|
+
await Bun.write(join(dir, rangeFileName), text.trim());
|
|
92
|
+
|
|
93
|
+
// 원본 파일 삭제 (range 파일명과 다른 경우만)
|
|
94
|
+
await Promise.all(
|
|
95
|
+
targets.filter((f) => f.fileName !== rangeFileName).map((f) => unlink(join(dir, f.fileName))),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const scopeLabel = scope.kind === "user" ? `user:${scope.userId}` : `channel:${scope.channelId}`;
|
|
99
|
+
console.log(
|
|
100
|
+
`[Compress] 압축 완료 (scope: ${scopeLabel}, ${targets.length}개 → ${rangeFileName})`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { appendMemory, readGlobalMemory, readMemory } from "./index";
|
|
6
|
+
import type { MemoryScope } from "./types";
|
|
7
|
+
|
|
8
|
+
const user: MemoryScope = { kind: "user", userId: "u123" };
|
|
9
|
+
const channel: MemoryScope = { kind: "channel", channelId: "c456" };
|
|
10
|
+
const tr = { start: new Date("2026-02-01T00:00:00Z"), end: new Date("2026-02-28T00:00:00Z") };
|
|
11
|
+
|
|
12
|
+
let tmpDir: string;
|
|
13
|
+
const origU1zHome = process.env.U1Z_HOME;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmpDir = await mkdtemp(join(tmpdir(), "u1z-mem-test-"));
|
|
17
|
+
process.env.U1Z_HOME = tmpDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
process.env.U1Z_HOME = origU1zHome;
|
|
22
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("readGlobalMemory", () => {
|
|
26
|
+
test("MEMORY.txt 없으면 빈 문자열 반환", async () => {
|
|
27
|
+
expect(await readGlobalMemory()).toBe("");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("MEMORY.txt 있으면 내용 반환", async () => {
|
|
31
|
+
const memDir = join(tmpDir, ".u1z", "memory");
|
|
32
|
+
await Bun.write(join(memDir, "MEMORY.txt"), "전역 기억");
|
|
33
|
+
expect(await readGlobalMemory()).toBe("전역 기억");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("readMemory", () => {
|
|
38
|
+
test("파일이 없으면 빈 배열 반환", async () => {
|
|
39
|
+
expect(await readMemory(user)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("기억 파일 내용 반환", async () => {
|
|
43
|
+
await appendMemory(user, "단기 기억 A", tr);
|
|
44
|
+
const result = await readMemory(user);
|
|
45
|
+
expect(result).toHaveLength(1);
|
|
46
|
+
expect(result[0].content).toContain("단기 기억 A");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("scope 격리 — 채널 A 내용이 채널 B에 포함되지 않음", async () => {
|
|
50
|
+
const chA: MemoryScope = { kind: "channel", channelId: "ch-a" };
|
|
51
|
+
const chB: MemoryScope = { kind: "channel", channelId: "ch-b" };
|
|
52
|
+
await appendMemory(chA, "채널 A 전용 기억", tr);
|
|
53
|
+
expect(await readMemory(chB)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("scope 격리 — user 기억이 channel에 포함되지 않음", async () => {
|
|
57
|
+
await appendMemory(user, "유저 전용 기억", tr);
|
|
58
|
+
expect(await readMemory(channel)).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("scope 격리 — user A 기억이 user B에 포함되지 않음", async () => {
|
|
62
|
+
const userA: MemoryScope = { kind: "user", userId: "u-a" };
|
|
63
|
+
const userB: MemoryScope = { kind: "user", userId: "u-b" };
|
|
64
|
+
await appendMemory(userA, "유저 A 전용 기억", tr);
|
|
65
|
+
expect(await readMemory(userB)).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("appendMemory", () => {
|
|
70
|
+
test("두 번 호출하면 같은 파일에 누적", async () => {
|
|
71
|
+
await appendMemory(user, "첫 번째", tr);
|
|
72
|
+
await appendMemory(user, "두 번째", tr);
|
|
73
|
+
const result = await readMemory(user);
|
|
74
|
+
expect(result).toHaveLength(1);
|
|
75
|
+
expect(result[0].content).toContain("첫 번째");
|
|
76
|
+
expect(result[0].content).toContain("두 번째");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("날짜 형식이 YYMMDD 6자리", async () => {
|
|
80
|
+
await appendMemory(user, "기억", tr);
|
|
81
|
+
const result = await readMemory(user);
|
|
82
|
+
expect(result[0].date).toMatch(/^\d{6}$/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { MemoryScope } from "./types";
|
|
4
|
+
|
|
5
|
+
export type { MemoryScope };
|
|
6
|
+
|
|
7
|
+
function resolveMemoryRoot(): string {
|
|
8
|
+
return join(process.env.U1Z_HOME, ".u1z", "memory");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function scopeDir(root: string, scope: MemoryScope): string {
|
|
12
|
+
switch (scope.kind) {
|
|
13
|
+
case "user":
|
|
14
|
+
return join(root, "user", scope.userId);
|
|
15
|
+
case "channel":
|
|
16
|
+
return join(root, "channel", scope.channelId);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toYYMMDD(date: Date): string {
|
|
21
|
+
const yy = String(date.getFullYear()).slice(-2);
|
|
22
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
23
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
24
|
+
return `${yy}${mm}${dd}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readFileIfExists(filePath: string): Promise<string> {
|
|
28
|
+
const file = Bun.file(filePath);
|
|
29
|
+
if (!(await file.exists())) return "";
|
|
30
|
+
return (await file.text()).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveScopeDir(scope: MemoryScope): string {
|
|
34
|
+
return scopeDir(resolveMemoryRoot(), scope);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function listScopedFiles(scope: MemoryScope): Promise<{ dir: string; files: string[] }> {
|
|
38
|
+
const dir = resolveScopeDir(scope);
|
|
39
|
+
let entries: string[] = [];
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir);
|
|
42
|
+
} catch (err: unknown) {
|
|
43
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
44
|
+
}
|
|
45
|
+
return { dir, files: entries.filter((f) => /^\d{6}(~\d{6})?\.txt$/.test(f)).sort() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type MemoryData = { date: string; content: string };
|
|
49
|
+
|
|
50
|
+
export async function readMemory(scope: MemoryScope): Promise<MemoryData[]> {
|
|
51
|
+
const { dir, files } = await listScopedFiles(scope);
|
|
52
|
+
const memories: MemoryData[] = [];
|
|
53
|
+
for (const fileName of files) {
|
|
54
|
+
const content = await readFileIfExists(join(dir, fileName));
|
|
55
|
+
if (content) memories.push({ date: fileName.replace(".txt", ""), content });
|
|
56
|
+
}
|
|
57
|
+
return memories;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type MemoryFile = { fileName: string; size: number };
|
|
61
|
+
|
|
62
|
+
export async function listMemoryFiles(scope: MemoryScope): Promise<MemoryFile[]> {
|
|
63
|
+
const { dir, files } = await listScopedFiles(scope);
|
|
64
|
+
return Promise.all(
|
|
65
|
+
files.map(async (fileName) => ({ fileName, size: Bun.file(join(dir, fileName)).size })),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* $U1Z_HOME/.u1z/memory/MEMORY.txt — 전역 공유 기억 파일.
|
|
71
|
+
* 사용자가 직접 관리하며, 없으면 빈 문자열 반환.
|
|
72
|
+
*/
|
|
73
|
+
export async function readGlobalMemory(): Promise<string> {
|
|
74
|
+
const root = resolveMemoryRoot();
|
|
75
|
+
return readFileIfExists(join(root, "MEMORY.txt"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatTimeRange(start: Date, end: Date): string {
|
|
79
|
+
const fmt = (d: Date) =>
|
|
80
|
+
`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
81
|
+
return `[${fmt(start)} ~ ${fmt(end)}]`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 오늘 날짜의 기억 파일(YYMMDD.txt)에 텍스트를 추가한다.
|
|
86
|
+
* timeRange가 있으면 대화 시작~종료 시각을 헤더로 붙인다.
|
|
87
|
+
*/
|
|
88
|
+
export async function appendMemory(
|
|
89
|
+
scope: MemoryScope,
|
|
90
|
+
text: string,
|
|
91
|
+
timeRange: { start: Date; end: Date },
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const root = resolveMemoryRoot();
|
|
94
|
+
const dir = scopeDir(root, scope);
|
|
95
|
+
await mkdir(dir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const entry = `${formatTimeRange(timeRange.start, timeRange.end)}\n${text.trim()}`;
|
|
98
|
+
|
|
99
|
+
const filePath = join(dir, `${toYYMMDD(timeRange.end)}.txt`);
|
|
100
|
+
const file = Bun.file(filePath);
|
|
101
|
+
const existing = ((await file.exists()) ? await file.text() : "").trimEnd();
|
|
102
|
+
await Bun.write(filePath, [existing, entry].filter(Boolean).join("\n\n"));
|
|
103
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { generateText } from "ai";
|
|
3
|
+
import { MODELS } from "@/ai/llm/model";
|
|
4
|
+
import { appendMemory } from "@/memory/index";
|
|
5
|
+
import type { MemoryScope } from "@/memory/types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 텍스트를 LLM으로 요약해 단기 기억에 저장한다.
|
|
9
|
+
* MEMORIZE.md가 없거나 비어있으면 스킵.
|
|
10
|
+
*/
|
|
11
|
+
export async function memorizeText(
|
|
12
|
+
text: string,
|
|
13
|
+
scope: MemoryScope,
|
|
14
|
+
timeRange: { start: Date; end: Date },
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const path = join(process.env.U1Z_HOME, ".u1z", "prompt", "MEMORIZE.md");
|
|
17
|
+
const file = Bun.file(path);
|
|
18
|
+
if (!(await file.exists())) return;
|
|
19
|
+
const systemPrompt = (await file.text()).trim();
|
|
20
|
+
if (!systemPrompt) {
|
|
21
|
+
console.error("[Memorize] MEMORIZE.md가 비어있습니다. 스킵합니다.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { text: summary } = await generateText({
|
|
26
|
+
model: MODELS.xs,
|
|
27
|
+
system: systemPrompt,
|
|
28
|
+
messages: [{ role: "user", content: text }],
|
|
29
|
+
maxOutputTokens: 512,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!summary.trim()) {
|
|
33
|
+
console.log("[Memorize] 요약 내용이 비어있습니다. 스킵합니다.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await appendMemory(scope, summary.trim(), timeRange);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type MemoryScope = { kind: "user"; userId: string } | { kind: "channel"; channelId: string };
|