@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,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { executeTextEditor } from "./textEditor";
|
|
4
|
+
|
|
5
|
+
const TMP = `/tmp/u1z-text-editor-test-${Date.now()}.txt`;
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
const f = Bun.file(TMP);
|
|
9
|
+
if (await f.exists()) await unlink(TMP);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
const f = Bun.file(TMP);
|
|
14
|
+
if (await f.exists()) await unlink(TMP);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("textEditor tool", () => {
|
|
18
|
+
test("create: creates file with given content", async () => {
|
|
19
|
+
const result = await executeTextEditor({
|
|
20
|
+
command: "create",
|
|
21
|
+
path: TMP,
|
|
22
|
+
file_text: "hello world",
|
|
23
|
+
});
|
|
24
|
+
expect(result).toMatchObject({ success: true });
|
|
25
|
+
expect(await Bun.file(TMP).text()).toBe("hello world");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("view: returns full file content", async () => {
|
|
29
|
+
await Bun.write(TMP, "line1\nline2\nline3");
|
|
30
|
+
const result = await executeTextEditor({ command: "view", path: TMP });
|
|
31
|
+
expect(result).toMatchObject({ content: "line1\nline2\nline3" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("view: returns sliced content with view_range", async () => {
|
|
35
|
+
await Bun.write(TMP, "line1\nline2\nline3\nline4");
|
|
36
|
+
const result = await executeTextEditor({
|
|
37
|
+
command: "view",
|
|
38
|
+
path: TMP,
|
|
39
|
+
view_range: { start: 2, end: 3 },
|
|
40
|
+
});
|
|
41
|
+
expect(result).toMatchObject({ content: "line2\nline3" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("view: returns error for missing file", async () => {
|
|
45
|
+
const result = await executeTextEditor({ command: "view", path: "/tmp/nonexistent-u1z.txt" });
|
|
46
|
+
expect(result).toMatchObject({ error: expect.stringContaining("not found") });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("str_replace: replaces matching string", async () => {
|
|
50
|
+
await Bun.write(TMP, "hello world");
|
|
51
|
+
const result = await executeTextEditor({
|
|
52
|
+
command: "str_replace",
|
|
53
|
+
path: TMP,
|
|
54
|
+
old_str: "world",
|
|
55
|
+
new_str: "universe",
|
|
56
|
+
});
|
|
57
|
+
expect(result).toMatchObject({ success: true });
|
|
58
|
+
expect(await Bun.file(TMP).text()).toBe("hello universe");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("str_replace: returns error when old_str not found", async () => {
|
|
62
|
+
await Bun.write(TMP, "hello world");
|
|
63
|
+
const result = await executeTextEditor({
|
|
64
|
+
command: "str_replace",
|
|
65
|
+
path: TMP,
|
|
66
|
+
old_str: "notexist",
|
|
67
|
+
new_str: "x",
|
|
68
|
+
});
|
|
69
|
+
expect(result).toMatchObject({ error: expect.stringContaining("not found") });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("insert: inserts line after given line number", async () => {
|
|
73
|
+
await Bun.write(TMP, "line1\nline2\nline3");
|
|
74
|
+
const result = await executeTextEditor({
|
|
75
|
+
command: "insert",
|
|
76
|
+
path: TMP,
|
|
77
|
+
insert_line: 1,
|
|
78
|
+
new_str: "inserted",
|
|
79
|
+
});
|
|
80
|
+
expect(result).toMatchObject({ success: true });
|
|
81
|
+
const content = await Bun.file(TMP).text();
|
|
82
|
+
expect(content).toBe("line1\ninserted\nline2\nline3");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("insert: prepends when insert_line is 0", async () => {
|
|
86
|
+
await Bun.write(TMP, "line1\nline2");
|
|
87
|
+
await executeTextEditor({ command: "insert", path: TMP, insert_line: 0, new_str: "prepended" });
|
|
88
|
+
const content = await Bun.file(TMP).text();
|
|
89
|
+
expect(content).toBe("prepended\nline1\nline2");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
type TextEditorInput = {
|
|
5
|
+
command: "view" | "str_replace" | "create" | "insert";
|
|
6
|
+
path: string;
|
|
7
|
+
view_range?: { start: number; end: number };
|
|
8
|
+
old_str?: string;
|
|
9
|
+
new_str?: string;
|
|
10
|
+
file_text?: string;
|
|
11
|
+
insert_line?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function executeTextEditor({
|
|
15
|
+
command,
|
|
16
|
+
path,
|
|
17
|
+
view_range,
|
|
18
|
+
old_str,
|
|
19
|
+
new_str,
|
|
20
|
+
file_text,
|
|
21
|
+
insert_line,
|
|
22
|
+
}: TextEditorInput) {
|
|
23
|
+
switch (command) {
|
|
24
|
+
case "view": {
|
|
25
|
+
const file = Bun.file(path);
|
|
26
|
+
if (!(await file.exists())) return { error: `File not found: ${path}` };
|
|
27
|
+
const text = await file.text();
|
|
28
|
+
if (!view_range) return { content: text };
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
const { start, end } = view_range;
|
|
31
|
+
const sliced = lines.slice(start - 1, end).join("\n");
|
|
32
|
+
return { content: sliced };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case "create": {
|
|
36
|
+
await Bun.write(path, file_text ?? "");
|
|
37
|
+
return { success: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case "str_replace": {
|
|
41
|
+
if (old_str === undefined || new_str === undefined)
|
|
42
|
+
return { error: "str_replace requires old_str and new_str" };
|
|
43
|
+
const file = Bun.file(path);
|
|
44
|
+
if (!(await file.exists())) return { error: `File not found: ${path}` };
|
|
45
|
+
const text = await file.text();
|
|
46
|
+
if (!text.includes(old_str)) return { error: `old_str not found in ${path}` };
|
|
47
|
+
await Bun.write(path, text.replace(old_str, new_str));
|
|
48
|
+
return { success: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case "insert": {
|
|
52
|
+
if (new_str === undefined || insert_line === undefined)
|
|
53
|
+
return { error: "insert requires new_str and insert_line" };
|
|
54
|
+
const file = Bun.file(path);
|
|
55
|
+
if (!(await file.exists())) return { error: `File not found: ${path}` };
|
|
56
|
+
const text = await file.text();
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
lines.splice(insert_line, 0, new_str);
|
|
59
|
+
await Bun.write(path, lines.join("\n"));
|
|
60
|
+
return { success: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const textEditor = tool({
|
|
66
|
+
description:
|
|
67
|
+
"View or edit a file. Supports view (read), str_replace (find & replace), create (new file), and insert (add lines at a position).",
|
|
68
|
+
inputSchema: z.object({
|
|
69
|
+
command: z.enum(["view", "str_replace", "create", "insert"]),
|
|
70
|
+
path: z.string().describe("Absolute file path"),
|
|
71
|
+
view_range: z
|
|
72
|
+
.object({
|
|
73
|
+
start: z.number().describe("Start line (1-based, inclusive)"),
|
|
74
|
+
end: z.number().describe("End line (1-based, inclusive)"),
|
|
75
|
+
})
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Line range for view. Omit to view entire file."),
|
|
78
|
+
old_str: z.string().optional().describe("Exact string to replace (for str_replace)"),
|
|
79
|
+
new_str: z.string().optional().describe("Replacement string (for str_replace or insert)"),
|
|
80
|
+
file_text: z.string().optional().describe("Full file content to write (for create)"),
|
|
81
|
+
insert_line: z
|
|
82
|
+
.number()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("Line number after which to insert new_str (for insert, 0 = prepend)"),
|
|
85
|
+
}),
|
|
86
|
+
execute: executeTextEditor,
|
|
87
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { tavilyClient as client } from "./tavilyClient";
|
|
4
|
+
|
|
5
|
+
export const webFetch = tool({
|
|
6
|
+
description:
|
|
7
|
+
"Fetch and extract the full content of specific URLs. Use this when you need to read the actual content of a webpage. " +
|
|
8
|
+
"**For YouTube URLs, use `analyze_youtube` instead.** `web_fetch` cannot extract YouTube video content — it only retrieves the page HTML. `analyze_youtube` extracts subtitles and metadata for proper analysis.",
|
|
9
|
+
inputSchema: z.object({
|
|
10
|
+
urls: z.array(z.string()).max(20).describe("URLs to extract content from (max 20)"),
|
|
11
|
+
query: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Optional intent hint to re-rank content chunks by relevance"),
|
|
15
|
+
format: z
|
|
16
|
+
.enum(["markdown", "text"])
|
|
17
|
+
.default("markdown")
|
|
18
|
+
.describe("Output format for extracted content"),
|
|
19
|
+
extract_depth: z
|
|
20
|
+
.enum(["basic", "advanced"])
|
|
21
|
+
.default("basic")
|
|
22
|
+
.describe(
|
|
23
|
+
"basic: fast extraction / advanced: handles JS-rendered pages, more complete content",
|
|
24
|
+
),
|
|
25
|
+
}),
|
|
26
|
+
execute: async ({ urls, query, format, extract_depth }) => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await client.extract(urls, { query, extractDepth: extract_depth, format });
|
|
29
|
+
return {
|
|
30
|
+
results: response.results.map((r) => ({ url: r.url, content: r.rawContent })),
|
|
31
|
+
failed: response.failedResults.map((r) => ({ url: r.url, error: r.error })),
|
|
32
|
+
};
|
|
33
|
+
} catch (e) {
|
|
34
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
35
|
+
if (msg.includes("401") || msg.toLowerCase().includes("unauthorized")) {
|
|
36
|
+
throw new Error("Tavily API key is invalid or expired");
|
|
37
|
+
}
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { tavilyClient as client } from "./tavilyClient";
|
|
4
|
+
|
|
5
|
+
export const webSearch = tool({
|
|
6
|
+
description:
|
|
7
|
+
"Search the web and return relevant results. Use when the user asks about current events, real-time data, recent news, or facts that may have changed after your knowledge cutoff.",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
query: z.string().describe("Search query"),
|
|
10
|
+
search_depth: z
|
|
11
|
+
.enum(["basic", "advanced"])
|
|
12
|
+
.default("basic")
|
|
13
|
+
.describe(
|
|
14
|
+
"Use 'basic' for most queries. Use 'advanced' when the question is technical or requires comprehensive coverage (e.g. researching a specific error, comparing products).",
|
|
15
|
+
),
|
|
16
|
+
topic: z
|
|
17
|
+
.enum(["general", "news", "finance"])
|
|
18
|
+
.default("general")
|
|
19
|
+
.describe(
|
|
20
|
+
"Use 'news' when the user asks about recent events or breaking news. Use 'finance' for stock prices, earnings, or market data. Use 'general' otherwise.",
|
|
21
|
+
),
|
|
22
|
+
max_results: z
|
|
23
|
+
.number()
|
|
24
|
+
.min(1)
|
|
25
|
+
.max(10)
|
|
26
|
+
.default(5)
|
|
27
|
+
.describe(
|
|
28
|
+
"Number of results to return (1–10). Use 3 for simple factual queries, 5 for general use, 10 when the user needs broad coverage.",
|
|
29
|
+
),
|
|
30
|
+
include_answer: z
|
|
31
|
+
.enum(["false", "basic", "advanced"])
|
|
32
|
+
.default("false")
|
|
33
|
+
.describe(
|
|
34
|
+
"Include an AI-generated answer. Use 'basic' when a quick summary suffices. Use 'advanced' for complex questions where a detailed synthesized answer is more useful than raw results. Use 'false' when the user wants to see sources directly.",
|
|
35
|
+
),
|
|
36
|
+
time_range: z
|
|
37
|
+
.enum(["day", "week", "month", "year"])
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
"Filter results by publish date. Use 'day' for breaking news or today's updates, 'week' for recent developments, 'month' for the latest trends. Omit when recency doesn't matter.",
|
|
41
|
+
),
|
|
42
|
+
country: z
|
|
43
|
+
.enum(["south korea", "united states"])
|
|
44
|
+
.optional()
|
|
45
|
+
.describe(
|
|
46
|
+
"Boost results from a specific country. Use 'south korea' when the query is about Korean topics (domestic news, local services, Korean-language content). Use 'united states' for US-specific queries. Only applies when topic is 'general'.",
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
execute: async ({
|
|
50
|
+
query,
|
|
51
|
+
search_depth,
|
|
52
|
+
topic,
|
|
53
|
+
max_results,
|
|
54
|
+
include_answer,
|
|
55
|
+
time_range,
|
|
56
|
+
country,
|
|
57
|
+
}) => {
|
|
58
|
+
try {
|
|
59
|
+
const response = await client.search(query, {
|
|
60
|
+
searchDepth: search_depth,
|
|
61
|
+
topic,
|
|
62
|
+
maxResults: max_results,
|
|
63
|
+
includeAnswer: include_answer === "false" ? false : include_answer,
|
|
64
|
+
timeRange: time_range,
|
|
65
|
+
country,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
answer: response.answer,
|
|
69
|
+
results: response.results.map((r) => ({
|
|
70
|
+
title: r.title,
|
|
71
|
+
url: r.url,
|
|
72
|
+
content: r.content,
|
|
73
|
+
score: r.score,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
78
|
+
if (msg.includes("401") || msg.toLowerCase().includes("unauthorized")) {
|
|
79
|
+
throw new Error("Tavily API key is invalid or expired");
|
|
80
|
+
}
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { c } from "../utils/color";
|
|
5
|
+
|
|
6
|
+
type CheckResult = { label: string; ok: boolean; hint?: string; optional?: boolean };
|
|
7
|
+
|
|
8
|
+
async function spawnOutput(args: string[]): Promise<{ code: number; stdout: string }> {
|
|
9
|
+
try {
|
|
10
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
11
|
+
const stdout = await new Response(proc.stdout).text();
|
|
12
|
+
return { code: await proc.exited, stdout: stdout.trim() };
|
|
13
|
+
} catch {
|
|
14
|
+
return { code: 127, stdout: "" };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function checks(): Promise<CheckResult[]> {
|
|
19
|
+
const results: CheckResult[] = [];
|
|
20
|
+
const home = homedir();
|
|
21
|
+
|
|
22
|
+
// 1. bun
|
|
23
|
+
const bunPath = Bun.which("bun");
|
|
24
|
+
results.push({
|
|
25
|
+
label: "bun 설치",
|
|
26
|
+
ok: !!bunPath,
|
|
27
|
+
hint: bunPath ? undefined : "https://bun.sh/install",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// 2. yt-dlp
|
|
31
|
+
results.push({
|
|
32
|
+
label: "yt-dlp 설치",
|
|
33
|
+
ok: !!Bun.which("yt-dlp"),
|
|
34
|
+
hint: "sudo apt install -y yt-dlp",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// 3. ffmpeg
|
|
38
|
+
results.push({
|
|
39
|
+
label: "ffmpeg 설치",
|
|
40
|
+
ok: !!Bun.which("ffmpeg"),
|
|
41
|
+
hint: "sudo apt install -y ffmpeg",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 4. gpg / pass (선택)
|
|
45
|
+
const gpgOk = !!Bun.which("gpg");
|
|
46
|
+
const passOk = !!Bun.which("pass");
|
|
47
|
+
results.push({
|
|
48
|
+
label: "gpg + pass 설치 (선택)",
|
|
49
|
+
ok: gpgOk && passOk,
|
|
50
|
+
hint: gpgOk && passOk ? undefined : "sudo apt install -y gnupg pass",
|
|
51
|
+
optional: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 5. systemd user linger
|
|
55
|
+
const currentUser = process.env.USER ?? "";
|
|
56
|
+
const lingerFile = `/var/lib/systemd/linger/${currentUser}`;
|
|
57
|
+
results.push({
|
|
58
|
+
label: "systemd user linger 활성화",
|
|
59
|
+
ok: existsSync(lingerFile),
|
|
60
|
+
hint: `sudo loginctl enable-linger ${currentUser}`,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 6. SSH authorized_keys
|
|
64
|
+
const sshKeyFile = join(home, ".ssh", "authorized_keys");
|
|
65
|
+
results.push({
|
|
66
|
+
label: "SSH authorized_keys 설정",
|
|
67
|
+
ok: existsSync(sshKeyFile),
|
|
68
|
+
hint: "~/.ssh/authorized_keys 파일이 없습니다",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 7. U1Z_HOME 구조
|
|
72
|
+
const u1zHome = process.env.U1Z_HOME ?? "";
|
|
73
|
+
const u1zDir = join(u1zHome, ".u1z");
|
|
74
|
+
const promptDir = join(u1zDir, "prompt");
|
|
75
|
+
results.push({
|
|
76
|
+
label: "U1Z_HOME 디렉토리 구조",
|
|
77
|
+
ok: !!u1zHome && existsSync(promptDir),
|
|
78
|
+
hint: u1zHome ? `u1z init 실행 필요 (${promptDir} 없음)` : "U1Z_HOME 환경변수 미설정",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 8. .env 필수 변수
|
|
82
|
+
const envPath = u1zHome ? join(u1zHome, ".env") : "";
|
|
83
|
+
const requiredVars = [
|
|
84
|
+
"DISCORD_BOT_TOKEN",
|
|
85
|
+
"OPENAI_API_KEY",
|
|
86
|
+
"GOOGLE_GENERATIVE_AI_API_KEY",
|
|
87
|
+
"TAVILY_API_KEY",
|
|
88
|
+
];
|
|
89
|
+
const missingVars = requiredVars.filter((k) => !process.env[k]);
|
|
90
|
+
results.push({
|
|
91
|
+
label: ".env 필수 변수",
|
|
92
|
+
ok: missingVars.length === 0,
|
|
93
|
+
hint:
|
|
94
|
+
missingVars.length > 0
|
|
95
|
+
? `누락: ${missingVars.join(", ")} — ${envPath || "U1Z_HOME/.env"}`
|
|
96
|
+
: undefined,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 9. systemd u1z.service 등록 여부
|
|
100
|
+
const { code: statusCode } = await spawnOutput(["systemctl", "--user", "is-enabled", "u1z"]);
|
|
101
|
+
results.push({
|
|
102
|
+
label: "u1z.service systemd 등록",
|
|
103
|
+
ok: statusCode === 0,
|
|
104
|
+
hint: "u1z init 실행 필요",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 10. u1z.service 현재 실행 중
|
|
108
|
+
const { code: activeCode } = await spawnOutput(["systemctl", "--user", "is-active", "u1z"]);
|
|
109
|
+
results.push({
|
|
110
|
+
label: "u1z.service 실행 중",
|
|
111
|
+
ok: activeCode === 0,
|
|
112
|
+
hint: "systemctl --user start u1z",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function doctor(): Promise<void> {
|
|
119
|
+
console.log(c.bold("\nu1z doctor\n"));
|
|
120
|
+
|
|
121
|
+
const results = await checks();
|
|
122
|
+
let allOk = true;
|
|
123
|
+
|
|
124
|
+
for (const { label, ok, hint, optional } of results) {
|
|
125
|
+
const icon = ok ? c.green("✓") : optional ? c.yellow("–") : c.red("✗");
|
|
126
|
+
console.log(` ${icon} ${label}`);
|
|
127
|
+
if (!ok && hint) console.log(` ${c.dim(hint)}`);
|
|
128
|
+
if (!ok && !optional) allOk = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log();
|
|
132
|
+
if (allOk) {
|
|
133
|
+
console.log(c.green(c.bold("모든 항목 정상")));
|
|
134
|
+
} else {
|
|
135
|
+
console.log(c.yellow("일부 항목을 확인해주세요."));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { c } from "../utils/color";
|
|
5
|
+
import { ask, confirm } from "../utils/prompt";
|
|
6
|
+
|
|
7
|
+
async function run(args: string[]): Promise<void> {
|
|
8
|
+
const proc = Bun.spawn(args, { stdout: "inherit", stderr: "inherit" });
|
|
9
|
+
const code = await proc.exited;
|
|
10
|
+
if (code !== 0) throw new Error(`'${args.join(" ")}' 실패 (exit ${code})`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function step(label: string, fn: () => Promise<void>): Promise<void> {
|
|
14
|
+
console.log(`\n${c.bold(`▶ ${label}`)}`);
|
|
15
|
+
try {
|
|
16
|
+
await fn();
|
|
17
|
+
console.log(c.green(" 완료"));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error(c.red(` 실패: ${err instanceof Error ? err.message : err}`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir(path: string): void {
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
mkdirSync(path, { recursive: true });
|
|
27
|
+
console.log(c.dim(` mkdir ${path}`));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function init(): Promise<void> {
|
|
32
|
+
console.log(c.bold("\nu1z init"));
|
|
33
|
+
|
|
34
|
+
// 1. U1Z_HOME 경로
|
|
35
|
+
const u1zHome = await ask("U1Z_HOME 경로", process.env.HOME ?? homedir());
|
|
36
|
+
if (!u1zHome) {
|
|
37
|
+
console.error(c.red("U1Z_HOME 경로가 필요합니다."));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. 디렉토리 구조 생성
|
|
42
|
+
await step("디렉토리 구조 생성", async () => {
|
|
43
|
+
const u1zDir = join(u1zHome, ".u1z");
|
|
44
|
+
ensureDir(join(u1zDir, "prompt"));
|
|
45
|
+
ensureDir(join(u1zDir, "memory"));
|
|
46
|
+
ensureDir(join(u1zDir, "skills"));
|
|
47
|
+
|
|
48
|
+
// 프롬프트 템플릿 복사 (존재하면 스킵)
|
|
49
|
+
const bootstrapDir = join(import.meta.dir, "../../../u1z_home_bootstrap/.u1z");
|
|
50
|
+
for (const file of ["BASE.md", "MEMORIZE.md", "DREAM.md"]) {
|
|
51
|
+
const dest = join(u1zDir, "prompt", file);
|
|
52
|
+
if (!existsSync(dest)) {
|
|
53
|
+
copyFileSync(join(bootstrapDir, "prompt", file), dest);
|
|
54
|
+
console.log(c.dim(` copied ${file}`));
|
|
55
|
+
} else {
|
|
56
|
+
console.log(c.dim(` skip ${file} (already exists)`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// 3. .env 설정
|
|
62
|
+
const envPath = join(u1zHome, ".env");
|
|
63
|
+
await step(".env 설정", async () => {
|
|
64
|
+
if (existsSync(envPath)) {
|
|
65
|
+
const overwrite = await confirm(".env 파일이 이미 존재합니다. 덮어쓸까요?", false);
|
|
66
|
+
if (!overwrite) {
|
|
67
|
+
console.log(c.dim(" 기존 .env 유지"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const vars: Record<string, string> = {};
|
|
73
|
+
for (const key of [
|
|
74
|
+
"DISCORD_BOT_TOKEN",
|
|
75
|
+
"OPENAI_API_KEY",
|
|
76
|
+
"GOOGLE_GENERATIVE_AI_API_KEY",
|
|
77
|
+
"TAVILY_API_KEY",
|
|
78
|
+
]) {
|
|
79
|
+
vars[key] = await ask(key);
|
|
80
|
+
if (!vars[key]) {
|
|
81
|
+
throw new Error(`${key}는 필수 항목입니다.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
vars.U1Z_HOME = u1zHome;
|
|
85
|
+
vars.PORT = (await ask("PORT", "3000")) || "3000";
|
|
86
|
+
|
|
87
|
+
const content = Object.entries(vars)
|
|
88
|
+
.map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`)
|
|
89
|
+
.join("\n");
|
|
90
|
+
writeFileSync(envPath, `${content}\n`, "utf-8");
|
|
91
|
+
console.log(c.dim(` wrote ${envPath}`));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 4. systemd unit 파일 설정
|
|
95
|
+
await step("systemd user 서비스 설정", async () => {
|
|
96
|
+
const serviceDir = join(homedir(), ".config", "systemd", "user");
|
|
97
|
+
const servicePath = join(serviceDir, "u1z.service");
|
|
98
|
+
|
|
99
|
+
ensureDir(serviceDir);
|
|
100
|
+
|
|
101
|
+
const u1zBin = Bun.which("u1z") ?? join(homedir(), ".bun", "bin", "u1z");
|
|
102
|
+
|
|
103
|
+
const serviceContent = `[Unit]
|
|
104
|
+
Description=u1z AI 집 비서
|
|
105
|
+
After=network.target
|
|
106
|
+
|
|
107
|
+
[Service]
|
|
108
|
+
Type=simple
|
|
109
|
+
WorkingDirectory=${u1zHome}
|
|
110
|
+
ExecStart=${u1zBin} server
|
|
111
|
+
EnvironmentFile=${envPath}
|
|
112
|
+
Restart=on-failure
|
|
113
|
+
RestartSec=5s
|
|
114
|
+
|
|
115
|
+
[Install]
|
|
116
|
+
WantedBy=default.target
|
|
117
|
+
`;
|
|
118
|
+
writeFileSync(servicePath, serviceContent, "utf-8");
|
|
119
|
+
console.log(c.dim(` wrote ${servicePath}`));
|
|
120
|
+
|
|
121
|
+
await run(["systemctl", "--user", "daemon-reload"]);
|
|
122
|
+
await run(["systemctl", "--user", "enable", "u1z"]);
|
|
123
|
+
await run(["systemctl", "--user", "restart", "u1z"]);
|
|
124
|
+
console.log(c.dim(" u1z.service enabled & restarted"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`\n${c.green(c.bold("초기화 완료"))}`);
|
|
128
|
+
console.log(c.dim(" 상태 확인: u1z status"));
|
|
129
|
+
console.log(c.dim(" 로그 확인: u1z logs"));
|
|
130
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function logs(argv: { n?: number }): Promise<void> {
|
|
2
|
+
const args = ["journalctl", "--user", "-u", "u1z"];
|
|
3
|
+
if (argv.n !== undefined) args.push("-n", String(argv.n));
|
|
4
|
+
args.push("-f");
|
|
5
|
+
const proc = Bun.spawn(args, {
|
|
6
|
+
stdout: "inherit",
|
|
7
|
+
stderr: "inherit",
|
|
8
|
+
stdin: "inherit",
|
|
9
|
+
});
|
|
10
|
+
process.exit(await proc.exited);
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { logger } from "hono/logger";
|
|
3
|
+
import { startDiscord } from "@/discord";
|
|
4
|
+
|
|
5
|
+
const REQUIRED_ENV_VARS = [
|
|
6
|
+
"DISCORD_BOT_TOKEN",
|
|
7
|
+
"OPENAI_API_KEY",
|
|
8
|
+
"GOOGLE_GENERATIVE_AI_API_KEY",
|
|
9
|
+
"TAVILY_API_KEY",
|
|
10
|
+
"U1Z_HOME",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export async function server(): Promise<void> {
|
|
14
|
+
for (const key of REQUIRED_ENV_VARS) {
|
|
15
|
+
if (!process.env[key]) throw new Error(`Missing required env var: ${key}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
app.use(logger());
|
|
20
|
+
app.get("/health", (c) => c.json({ status: "ok", timestamp: new Date().toISOString() }));
|
|
21
|
+
|
|
22
|
+
await startDiscord();
|
|
23
|
+
|
|
24
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
25
|
+
console.log(`[Server] Running on port ${port}`);
|
|
26
|
+
|
|
27
|
+
Bun.serve({ port, fetch: app.fetch });
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { confirm } from "../utils/prompt";
|
|
2
|
+
|
|
3
|
+
async function run(args: string[]): Promise<number> {
|
|
4
|
+
const proc = Bun.spawn(args, { stdout: "inherit", stderr: "inherit" });
|
|
5
|
+
return proc.exited;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function update(): Promise<void> {
|
|
9
|
+
const code = await run(["bun", "add", "-g", "@haenah/u1z@latest"]);
|
|
10
|
+
if (code !== 0) process.exit(code);
|
|
11
|
+
|
|
12
|
+
console.log("업데이트 완료.");
|
|
13
|
+
const restart = await confirm("서비스를 지금 재시작할까요?", true);
|
|
14
|
+
if (restart) {
|
|
15
|
+
const restartCode = await run(["systemctl", "--user", "restart", "u1z"]);
|
|
16
|
+
if (restartCode !== 0) process.exit(restartCode);
|
|
17
|
+
console.log("서비스 재시작 완료.");
|
|
18
|
+
} else {
|
|
19
|
+
console.log("재시작하려면: systemctl --user restart u1z");
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
import { hideBin } from "yargs/helpers";
|
|
4
|
+
|
|
5
|
+
yargs(hideBin(process.argv))
|
|
6
|
+
.scriptName("u1z")
|
|
7
|
+
.command("init", "대화형 초기 셋업 및 systemd 서비스 등록", {}, () =>
|
|
8
|
+
import("./commands/init").then((m) => m.init()),
|
|
9
|
+
)
|
|
10
|
+
.command("doctor", "사전 조건 및 환경 상태 점검", {}, () =>
|
|
11
|
+
import("./commands/doctor").then((m) => m.doctor()),
|
|
12
|
+
)
|
|
13
|
+
.command("status", "서비스 상태 확인", {}, () =>
|
|
14
|
+
import("./commands/status").then((m) => m.status()),
|
|
15
|
+
)
|
|
16
|
+
.command(
|
|
17
|
+
"logs",
|
|
18
|
+
"서비스 로그 스트리밍",
|
|
19
|
+
{ n: { type: "number", describe: "최근 N줄" } },
|
|
20
|
+
(argv) => import("./commands/logs").then((m) => m.logs(argv)),
|
|
21
|
+
)
|
|
22
|
+
.command("update", "최신 버전으로 업데이트", {}, () =>
|
|
23
|
+
import("./commands/update").then((m) => m.update()),
|
|
24
|
+
)
|
|
25
|
+
.command("server", false, {}, () => import("./commands/server").then((m) => m.server()))
|
|
26
|
+
.demandCommand(1)
|
|
27
|
+
.strict()
|
|
28
|
+
.help()
|
|
29
|
+
.parse();
|