@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
package/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# u1z — AI 집 비서
|
|
2
|
+
|
|
3
|
+
라즈베리파이에서 상시 구동되는 자율적인 AI 집 비서 시스템.
|
|
4
|
+
Discord를 채팅 인터페이스로 사용하며, 파일 관리, 캘린더, 식당 예약, 장보기, 웹 브라우징을 담당한다.
|
|
5
|
+
런타임 기준 루트는 `process.env.U1Z_HOME`이며, 시스템 프롬프트는 `${U1Z_HOME}/.u1z/prompt/BASE.md`를 사용한다.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 핵심 개념
|
|
10
|
+
|
|
11
|
+
**Conversation** — 1시간 이내의 연속된 대화 흐름. Prompt Caching 최적화의 기본 단위. 만료 시 단기 기억 요약 프로세스가 실행된다.
|
|
12
|
+
|
|
13
|
+
**채널 타입** — Discord는 두 가지 채널 타입을 지원한다.
|
|
14
|
+
- **Guild 채널**: 서버 공유 채널. 여러 사용자가 참여하며 발화자(User ID)를 식별한다.
|
|
15
|
+
- **DM**: 1:1 개인 채팅. 발화자 식별 없이 원문 그대로 전달한다.
|
|
16
|
+
|
|
17
|
+
**메모리** — 범주(user / channel)로 구분한다. 전역 공유 기억은 `MEMORY.txt` 단일 파일로 관리한다.
|
|
18
|
+
- **전역(MEMORY.txt)**: 모든 컨텍스트에 공통 주입되는 기억. 사용자가 직접 편집.
|
|
19
|
+
- **user**: DM 발화자별 개인 기억
|
|
20
|
+
- **channel**: 서버 채널별 기억
|
|
21
|
+
- **YYMMDD**: 일별 단기기억. Conversation 만료 시 자동 요약 추가.
|
|
22
|
+
|
|
23
|
+
**스킬** — `$U1Z_HOME/.u1z/skills/`에 저장된 작업별 가이드라인 파일. 시스템 프롬프트에는 `SKILLS.md`(목록)만 주입하고 적극적으로 활용하고 자기 주도적으로 관리할 수 있도록 프롬프팅한다.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 기술 스택
|
|
28
|
+
|
|
29
|
+
| 레이어 | 선택 |
|
|
30
|
+
| ------------ | ----------------------------- |
|
|
31
|
+
| 런타임 | Bun (TypeScript, ARM64) |
|
|
32
|
+
| HTTP 서버 | Hono |
|
|
33
|
+
| LLM | Vercel AI SDK (`ai`) + gpt5.2 |
|
|
34
|
+
| 채팅 | Discord.js |
|
|
35
|
+
| 웹 검색/추출 | Tavily API |
|
|
36
|
+
| 웹 브라우징 | agent-browser (Vercel Labs) |
|
|
37
|
+
| 컨텍스트 | Discord channel history |
|
|
38
|
+
| 패스워드 | Pass (GPG) |
|
|
39
|
+
| 스케줄러 | cron |
|
|
40
|
+
| 서비스 관리 | systemd |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 모듈 의존성
|
|
45
|
+
|
|
46
|
+
```mermaid
|
|
47
|
+
graph LR
|
|
48
|
+
conversation --> ai
|
|
49
|
+
conversation --> db
|
|
50
|
+
conversation --> memory
|
|
51
|
+
discord --> ai
|
|
52
|
+
discord --> conversation
|
|
53
|
+
discord --> db
|
|
54
|
+
discord --> memory
|
|
55
|
+
memory --> ai
|
|
56
|
+
server --> discord
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> `bun run dep-graph`으로 재생성 가능
|
|
60
|
+
|
|
61
|
+
하위 모듈(`prompt/`, `memory/`, `db/`)은 다른 도메인에 의존하지 않는 leaf 레이어다.
|
|
62
|
+
`conversation/`은 LLM + DB + memory를 조합해 대화 상태를 관리하고,
|
|
63
|
+
`discord/`는 모든 도메인을 통합해 봇의 실제 동작(메시지 수신 → LLM → 메모리 저장)을 구현한다.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 멀티유저 / 멀티채널
|
|
68
|
+
|
|
69
|
+
| 채널 | 히스토리 범위 | 용도 |
|
|
70
|
+
| --------- | ---------------------- | ---------------------- |
|
|
71
|
+
| 서버 공유 | 채널 단위 history 공유 | 장보기, 가족 일정 |
|
|
72
|
+
| 개인 DM | 사용자 DM history 분리 | 개인 일정, 민감한 작업 |
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
[Discord 서버 #집안일 채널] 나 + 와이프 + 비서봇
|
|
76
|
+
→ 발화자 식별 (Discord User ID)
|
|
77
|
+
→ 공유 컨텍스트 (장보기, 메뉴 등) + 개인 컨텍스트 분리
|
|
78
|
+
|
|
79
|
+
[Discord DM] 나 ↔ 비서봇 → 개인 일정, 민감한 작업
|
|
80
|
+
[Discord DM] 와이프 ↔ 비서봇 → 와이프 개인 일정
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| 컨텍스트 | 범위 | 예시 |
|
|
84
|
+
| -------- | --------------------- | ---------------------------- |
|
|
85
|
+
| 공유 | 서버 채널 참여자 전체 | 장보기 목록, 식단, 가족 일정 |
|
|
86
|
+
| 개인 | 발화자 본인만 | 개인 일정, 업무 |
|
|
87
|
+
|
|
88
|
+
길드 채널에서는 봇을 멘션해야 응답한다. DM은 별도 제한 없이 응답한다.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Discord Bot 설정
|
|
93
|
+
|
|
94
|
+
### Gateway Intents
|
|
95
|
+
|
|
96
|
+
**Privileged Intent** — Developer Portal > Bot > Privileged Gateway Intents에서 토글로 직접 활성화해야 한다:
|
|
97
|
+
|
|
98
|
+
| Developer Portal 레이블 | Intent | 이 프로젝트 |
|
|
99
|
+
| ----------------------- | ----------------- | ----------- |
|
|
100
|
+
| Message Content Intent | `MESSAGE_CONTENT` | **필수** |
|
|
101
|
+
| Server Members Intent | `GUILD_MEMBERS` | 불필요 |
|
|
102
|
+
| Presence Intent | `GUILD_PRESENCES` | 불필요 |
|
|
103
|
+
|
|
104
|
+
**Non-privileged Intent** — 포털 설정 없이 코드에서만 선언하면 된다:
|
|
105
|
+
|
|
106
|
+
| Intent | 용도 |
|
|
107
|
+
| ----------------- | --------------------- |
|
|
108
|
+
| `GUILDS` | 서버/채널 정보 수신 |
|
|
109
|
+
| `GUILD_MESSAGES` | 서버 채널 메시지 수신 |
|
|
110
|
+
| `DIRECT_MESSAGES` | DM 수신 |
|
|
111
|
+
|
|
112
|
+
### OAuth2 Scopes
|
|
113
|
+
|
|
114
|
+
봇 초대 URL 생성 시 (Developer Portal > OAuth2 > URL Generator) 아래 두 scope를 모두 체크해야 한다:
|
|
115
|
+
|
|
116
|
+
| Scope | 용도 |
|
|
117
|
+
| ----------------------- | -------------------------- |
|
|
118
|
+
| `bot` | 봇 계정으로 서버 참여 |
|
|
119
|
+
| `applications.commands` | Slash command 등록 및 사용 |
|
|
120
|
+
|
|
121
|
+
> `applications.commands`가 없으면 `/debug`, `/new` slash command가 동작하지 않는다.
|
|
122
|
+
> 기존에 `bot` scope만으로 초대한 경우, 새 URL로 재방문(재초대)하면 scope가 추가된다. 봇을 kick할 필요 없다.
|
|
123
|
+
|
|
124
|
+
### Bot Permissions
|
|
125
|
+
|
|
126
|
+
서버 초대 시 요청할 권한:
|
|
127
|
+
|
|
128
|
+
| Permission | 용도 |
|
|
129
|
+
| ---------------------- | --------------------------- |
|
|
130
|
+
| `View Channels` | 채널 접근 |
|
|
131
|
+
| `Send Messages` | 응답 전송 |
|
|
132
|
+
| `Read Message History` | 대화 컨텍스트 bootstrap |
|
|
133
|
+
| `Attach Files` | `/sysprompt` 파일 첨부 전송 |
|
|
134
|
+
|
|
135
|
+
### Slash Commands
|
|
136
|
+
|
|
137
|
+
봇 시동 시 `clientReady` 이벤트에서 자동으로 전역 등록된다.
|
|
138
|
+
|
|
139
|
+
| 커맨드 | 설명 | 공개 여부 |
|
|
140
|
+
| ------------ | ------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
141
|
+
| `/usage` | 현재 conversation의 토큰 사용량 및 비용 조회 | 본인에게만 (ephemeral) |
|
|
142
|
+
| `/new` | 현재 채널의 conversation을 초기화하고 새로 시작. 이때 메모리 워커가 동기적으로 실행되어 대기 필요 | 채널에 공개 |
|
|
143
|
+
| `/sysprompt` | 현재 conversation의 시스템 프롬프트를 `.md` 파일로 전송 | 본인에게만 (ephemeral) |
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Prompt structure
|
|
148
|
+
|
|
149
|
+
Conversation 단위로 프롬프트를 구성한다. 이는 GPT의 캐싱을 극대화하기 위함이다. Conversation의 시작 프롬프트는 아래와 같이 구성된다.
|
|
150
|
+
|
|
151
|
+
- System Prompt
|
|
152
|
+
- `${U1Z_HOME}/.u1z/prompt/BASE.md`
|
|
153
|
+
- `SKILLS.md`
|
|
154
|
+
- 기억
|
|
155
|
+
- 전역 기억: `memory/MEMORY.txt`
|
|
156
|
+
- 기억: `memory/channel/<channelId>/YYMMDD.md` 또는 `memory/user/<userId>/YYMMDD.md`
|
|
157
|
+
- Initial messages
|
|
158
|
+
- Conversation 만료 시 메모리에 요약된 내용으로 컨텍스트를 복원한다.
|
|
159
|
+
|
|
160
|
+
### interpolation
|
|
161
|
+
|
|
162
|
+
각 BASE.md는 **mustache** 문법(`{{}}`)을 이용한 프롬프트 인터폴레이션을 지원한다. 플레이스홀더는 `getSystemPrompt()` 호출 시 실제 값으로 치환되며, HTML 이스케이프는 비활성화되어 있다.
|
|
163
|
+
|
|
164
|
+
| 플레이스홀더 | 치환 값 |
|
|
165
|
+
| ----------------------- | ---------------------------- |
|
|
166
|
+
| `{{current_time_kokr}}` | 현재 일시 (KST, 한국어 형식) |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Tool loop
|
|
171
|
+
|
|
172
|
+
ai sdk의 ToolLoopAgent를 이용하여 구현한다.
|
|
173
|
+
스트리밍은 하지 않지만 중간중간 assistant message가 tool call과 함께 도착하는 경우 디스코드 메세지를 전송하여 사용자에게 진행 상황을 알려준다.
|
|
174
|
+
|
|
175
|
+
- `bash`
|
|
176
|
+
- `text_editor`
|
|
177
|
+
- `get_password`: Pass/GPG에서 자격증명 조회 (로그는 `[REDACTED]`)
|
|
178
|
+
- `web_search`: 웹 검색 (Tavily Search API). 검색 깊이, 주제(general/news/finance), 기간 필터, 국가 필터 지원.
|
|
179
|
+
- `web_fetch`: 웹 페이지 본문 추출 (Tavily Extract API). URL 배열 입력 → markdown/text 형식 반환. JS 렌더링 페이지는 advanced 모드 사용.
|
|
180
|
+
- `analyze_youtube`: `web_fetch`에 더해 추가적인 이점을 제공함. 유튜브 영상 ID를 입력받아 영상 제목, 설명, 스크립트로 추출한 transcript를 불러오고 경량 모델에게 readable한 분석 결과로 정리해서 반환시키는 subagent 실행
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 메모리 시스템
|
|
185
|
+
|
|
186
|
+
### 디렉토리 구조
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
$U1Z_HOME/.u1z/prompt/MEMORIZE.md # conversation 만료 후 메모리 요약 프롬프트
|
|
190
|
+
$U1Z_HOME/.u1z/memory/MEMORY.txt # 전역 공유 기억 (사용자 직접 편집)
|
|
191
|
+
$U1Z_HOME/.u1z/memory/user/<userId>/YYMMDD.md # DM 기억
|
|
192
|
+
$U1Z_HOME/.u1z/memory/channel/<channelId>/YYMMDD.md # 채널 기억
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### 원칙
|
|
196
|
+
|
|
197
|
+
- **기억 시점**: Conversation이 만료될 때 요약 프로세스가 실행되어 당일의 `YYMMDD.md` 파일에 내용을 추가한다.
|
|
198
|
+
- **Daily 기억**: 각 범주의 `YYMMDD.md`로 관리한다. "기억해야겠다"는 판단이 발생했을 때 그날의 **첫 메시지 기준으로 파일을 생성**한다.
|
|
199
|
+
- **용량 제한**: Daily 파일들의 용량 총합은 **100KB**를 상한으로 한다.
|
|
200
|
+
- **압축 규칙**: Daily 파일들의 용량 총합이 상한을 초과하면 별도 워커 프로세스가 **gemini3 flash**로 압축한다. 워커는 **매일 새벽 3시**에 실행한다. Daily 메모리 디렉토리에서 **오래된 내용 기준 50% 분량**을 읽어 **절반 수준으로 요약**한 뒤, `YYMMDD~YYMMDD.md` 형태의 **range 파일**로 저장한다. 이 과정 후 Daily의 총량은 **약 75KB 수준**으로 수렴한다. 압축 시 기억 충돌되면 최신 내용을 반영하도록 워커 프로세스 프롬프트에 명시한다.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 스킬 시스템
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
$U1Z_HOME/.u1z/skills/
|
|
208
|
+
├── shopping_coupang/
|
|
209
|
+
└── shopping_oasis/
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`SKILLS.md`에는 **스킬명 + 사용처(언제 어떤 작업에 쓰는지)**만 간단히 정리한다.
|
|
213
|
+
시스템 프롬프트에는 `SKILLS.md`만 주입한다.
|
|
214
|
+
|
|
215
|
+
### SKILLS.md 예시 (러프)
|
|
216
|
+
|
|
217
|
+
스킬 안내 프롬프트는 BASE에 넣는 것도 고민중
|
|
218
|
+
```text
|
|
219
|
+
# Available SKILLS
|
|
220
|
+
아래는 특정 작업을 수행할 때 사용 가능한 가이드라인입니다.
|
|
221
|
+
스킬은 $U1Z_HOME/.u1z/skills/{skill-name}/SKILL.md 파일들에 저장되어 있습니다. 작업 성격에 따라 적절한 스킬을 bash로 가져와 사용하세요.
|
|
222
|
+
또한 사용자는 스킬을 생성하거나 수정하라고 지시할 수 있습니다. 위 디렉토리 규칙에 맞게 스킬을 생성/수정하고 반드시 SKILLS.md에 스킬명과 사용처를 업데이트하세요.
|
|
223
|
+
|
|
224
|
+
## shopping_coupang
|
|
225
|
+
쿠팡에서 장보는 작업에 사용합니다. 식음료를 제외한 생필품 구매에도 사용합니다.
|
|
226
|
+
|
|
227
|
+
## shopping_oasis
|
|
228
|
+
오아시스에서 장보는 작업에 사용합니다.
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## LLM 모델 전략
|
|
234
|
+
|
|
235
|
+
gpt5.2를 기본으로 사용하고, 비용 절감을 위해 경량 모델을 작업 복잡도에 따라 구분하여 사용.
|
|
236
|
+
|
|
237
|
+
| 모델 | 담당 작업 |
|
|
238
|
+
| ------------------ | --------------------------------------------------------------------------------------------- |
|
|
239
|
+
| **gpt5.2** (기본) | Discord 메시지 수신/응답, 웹 브라우징, 장보기/예약 등 외부 서비스 |
|
|
240
|
+
| **gemini 3 flash** | 단기 기억 요약/정리, 중복 청크 합산, 상태 업데이트, youtube 요약, 혹은 e2e 테스트시 비용 절약 |
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 보안 설계
|
|
245
|
+
|
|
246
|
+
- **유저 격리**: `u1z` 유저로 실행
|
|
247
|
+
- **결제 차단**: iptables로 `u1z` 유저의 결제 도메인 OS 레벨 패킷 차단 — 프롬프트 인젝션으로도 뚫리지 않음
|
|
248
|
+
- **자격증명**: Pass(GPG) 암호화, 로그에 `[REDACTED]` 마스킹
|
|
249
|
+
- **승인 플로우**: Discord 채팅 확인만 유효, 웹 콘텐츠 승인 주장 무효
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 시작하기
|
|
254
|
+
|
|
255
|
+
### 요구사항
|
|
256
|
+
|
|
257
|
+
- Bun >= 1.0
|
|
258
|
+
- Raspberry Pi OS (ARM64) — 배포 대상
|
|
259
|
+
- Discord Bot Token (Developer Portal) — Gateway Intents / OAuth2 Scopes / 권한 설정은 [Discord Bot 설정](#discord-bot-설정) 참조
|
|
260
|
+
- OpenAI API Key (gpt5.2 사용)
|
|
261
|
+
- Tavily API Key (web_search, web_fetch 툴)
|
|
262
|
+
- `U1Z_HOME` 환경변수 (`.u1z/prompt/BASE.md`를 포함한 u1z 루트 경로, 예: `/home/assistant/u1z`)
|
|
263
|
+
- `DISCORD_HISTORY_LIMIT` 환경변수 (최근 히스토리 로드 개수, 기본값 `20`)
|
|
264
|
+
- GPG 키 + Pass 설치 (자격증명 관리)
|
|
265
|
+
|
|
266
|
+
### 로컬 개발 (macOS)
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
cp .env.example .env # U1Z_HOME, API 키, DISCORD_HISTORY_LIMIT 설정
|
|
270
|
+
bun install
|
|
271
|
+
bun run dev
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Further Works (Good to have, MVP 이후)
|
|
275
|
+
|
|
276
|
+
- 이미지/PDF 입력 (Discord 첨부파일 → LLM)
|
|
277
|
+
- 웹 브라우징 (agent-browser)
|
|
278
|
+
- 채널별 큐잉 처리(동시 messageCreate 순서 보장)
|
|
279
|
+
- 패스워드 관리 (Pass/GPG)
|
|
280
|
+
- API 인증 방식 설계 (관리용 엔드포인트 보호)
|
|
281
|
+
- `bun init` 커맨드 한 방에 런타임 셋업
|
|
282
|
+
- $U1Z_HOME/doc 에 산출 문서/보고서 등을 관리시키고, 인덱싱하여 search_doc() 툴로 grep보다 빠르게 검색하도록 함
|
|
283
|
+
|
|
284
|
+
---
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
CREATE TABLE conversations (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
channel_id TEXT NOT NULL,
|
|
4
|
+
scope TEXT NOT NULL,
|
|
5
|
+
model TEXT NOT NULL,
|
|
6
|
+
system_prompt TEXT NOT NULL,
|
|
7
|
+
last_message_at INTEGER NOT NULL,
|
|
8
|
+
memorized INTEGER NOT NULL DEFAULT 0
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE TABLE conversation_messages (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
14
|
+
role TEXT NOT NULL,
|
|
15
|
+
content TEXT NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX idx_messages_conv_created_at
|
|
20
|
+
ON conversation_messages(conversation_id, created_at);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX idx_conversations_last_message_at
|
|
23
|
+
ON conversations(last_message_at);
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@haenah/u1z",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI 집 비서 — Raspberry Pi home assistant powered by Claude + Discord",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"u1z": "src/cli/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"migrations/",
|
|
12
|
+
"u1z_home_bootstrap/",
|
|
13
|
+
"tsconfig.json"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "bun run --watch src/cli/index.ts server",
|
|
17
|
+
"start": "bun src/cli/index.ts server",
|
|
18
|
+
"test": "bun test src/",
|
|
19
|
+
"e2e": "bun test e2e/slash.test.ts && bun test e2e/basic.test.ts && bun test e2e/tools.test.ts && bun test e2e/memory.test.ts && bun test e2e/cache.test.ts && bun test e2e/recovery.test.ts",
|
|
20
|
+
"lint": "biome lint .",
|
|
21
|
+
"format": "biome format --write .",
|
|
22
|
+
"check": "biome check --write .",
|
|
23
|
+
"check:ci": "biome check .",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"bootstrap": "bun run src/cli/index.ts init",
|
|
26
|
+
"repl": "bun run scripts/repl.ts",
|
|
27
|
+
"dep-graph": "bun run scripts/dep-graph.ts",
|
|
28
|
+
"prepare": "husky"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@ai-sdk/google": "^3.0.31",
|
|
32
|
+
"@ai-sdk/openai": "^3.0.33",
|
|
33
|
+
"@tavily/core": "^0.7.2",
|
|
34
|
+
"ai": "^6.0.99",
|
|
35
|
+
"discord.js": "^14.18.0",
|
|
36
|
+
"hono": "^4.7.4",
|
|
37
|
+
"mustache": "^4.2.0",
|
|
38
|
+
"yargs": "^18.0.0",
|
|
39
|
+
"zod": "^4.3.6"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "^2.4.3",
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"@types/mustache": "^4.2.6",
|
|
45
|
+
"@types/yargs": "^17.0.35",
|
|
46
|
+
"husky": "^9.1.7",
|
|
47
|
+
"ts-morph": "^27.0.2",
|
|
48
|
+
"typescript": "^5.9.3"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { google } from "@ai-sdk/google";
|
|
2
|
+
import { openai } from "@ai-sdk/openai";
|
|
3
|
+
import type { LanguageModel } from "ai";
|
|
4
|
+
|
|
5
|
+
export const MODELS: Record<string, Extract<LanguageModel, { specificationVersion: "v3" }>> = {
|
|
6
|
+
md: openai("gpt-5.2"),
|
|
7
|
+
sm: google("gemini-3-flash-preview"),
|
|
8
|
+
xs: openai("gpt-5-nano"),
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export const PRICING = new Map([
|
|
12
|
+
[MODELS.md, { input: 1.75, cachedInput: 0.175, output: 14.0 }],
|
|
13
|
+
[MODELS.sm, { input: 0.5, cachedInput: 0.5, output: 3.0 }],
|
|
14
|
+
[MODELS.xs, { input: 0.05, cachedInput: 0.005, output: 0.4 }],
|
|
15
|
+
]);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { generateText, tool } from "ai";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { MODELS } from "../model";
|
|
7
|
+
|
|
8
|
+
const LANG_PRIORITY = ["ko", "en", "ja", "zh"];
|
|
9
|
+
|
|
10
|
+
const YtInfoSchema = z.object({
|
|
11
|
+
title: z.string().optional().default(""),
|
|
12
|
+
uploader: z.string().optional().default(""),
|
|
13
|
+
upload_date: z.string().optional().default(""),
|
|
14
|
+
view_count: z.number().optional().default(0),
|
|
15
|
+
description: z.string().optional().default(""),
|
|
16
|
+
tags: z.array(z.string()).optional().default([]),
|
|
17
|
+
language: z.string().nullish(),
|
|
18
|
+
subtitles: z.record(z.string(), z.unknown()).optional().default({}),
|
|
19
|
+
automatic_captions: z.record(z.string(), z.unknown()).optional().default({}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function spawnYtDlp(args: string[]): Promise<{ stderr: string; exitCode: number }> {
|
|
23
|
+
const proc = Bun.spawn(["yt-dlp", ...args], { stdout: "ignore", stderr: "pipe" });
|
|
24
|
+
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
25
|
+
return { stderr: stderr.trim(), exitCode };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 자막 언어 선택 우선순위
|
|
30
|
+
*
|
|
31
|
+
* 목표: LLM에 원본 언어 텍스트를 전달해 번역 손실을 방지한다.
|
|
32
|
+
*
|
|
33
|
+
* [1단계] 수동 자막 (업로더가 직접 작성 → 가장 신뢰도 높음)
|
|
34
|
+
* 1-1. LANG_PRIORITY 순서로 수동 자막 탐색 (ko → en → ja → zh)
|
|
35
|
+
* 1-2. LANG_PRIORITY에 없으면 아무 수동 자막이나 선택
|
|
36
|
+
*
|
|
37
|
+
* [2단계] 자동자막 — 원본 언어 우선 (번역 자막 회피)
|
|
38
|
+
* YouTube는 원본 언어 자동자막에 "{lang}-orig" 접미사를 붙인다.
|
|
39
|
+
* 예) 한국어 영상: "ko-orig" = 원본, "ko" = 번역본일 수 있음
|
|
40
|
+
* 2-1. originalLang이 있으면 "{originalLang}-orig" 키 우선 확인
|
|
41
|
+
* 2-2. originalLang 없어도 아무 "-orig" 키가 있으면 선택
|
|
42
|
+
* 2-3. "-orig" 없으면 originalLang → LANG_PRIORITY 순으로 폴백
|
|
43
|
+
* 2-4. 그래도 없으면 아무 자동자막이나 선택
|
|
44
|
+
*
|
|
45
|
+
* [실패] 수동/자동 자막 모두 없으면 null 반환 → "(자막 없음)" 처리
|
|
46
|
+
*/
|
|
47
|
+
function pickSubtitle(
|
|
48
|
+
subtitles: Record<string, unknown>,
|
|
49
|
+
automaticCaptions: Record<string, unknown>,
|
|
50
|
+
originalLang?: string,
|
|
51
|
+
): { lang: string; auto: boolean } | null {
|
|
52
|
+
// 1-1. LANG_PRIORITY 순 수동 자막
|
|
53
|
+
for (const lang of LANG_PRIORITY) {
|
|
54
|
+
if (subtitles[lang]) return { lang, auto: false };
|
|
55
|
+
}
|
|
56
|
+
// 1-2. 기타 수동 자막
|
|
57
|
+
const anyManual = Object.keys(subtitles)[0];
|
|
58
|
+
if (anyManual) return { lang: anyManual, auto: false };
|
|
59
|
+
|
|
60
|
+
// 2-1. {originalLang}-orig
|
|
61
|
+
if (originalLang && automaticCaptions[`${originalLang}-orig`])
|
|
62
|
+
return { lang: `${originalLang}-orig`, auto: true };
|
|
63
|
+
// 2-2. 아무 -orig
|
|
64
|
+
const anyOrig = Object.keys(automaticCaptions).find((k) => k.endsWith("-orig"));
|
|
65
|
+
if (anyOrig) return { lang: anyOrig, auto: true };
|
|
66
|
+
// 2-3. originalLang → LANG_PRIORITY 폴백
|
|
67
|
+
const autoPriority = originalLang
|
|
68
|
+
? [originalLang, ...LANG_PRIORITY.filter((l) => l !== originalLang)]
|
|
69
|
+
: LANG_PRIORITY;
|
|
70
|
+
for (const lang of autoPriority) {
|
|
71
|
+
if (automaticCaptions[lang]) return { lang, auto: true };
|
|
72
|
+
}
|
|
73
|
+
// 2-4. 아무 자동자막
|
|
74
|
+
const anyAuto = Object.keys(automaticCaptions)[0];
|
|
75
|
+
if (anyAuto) return { lang: anyAuto, auto: true };
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseVtt(vtt: string): string {
|
|
81
|
+
const lines = vtt.split("\n");
|
|
82
|
+
const cleaned: string[] = [];
|
|
83
|
+
let prev = "";
|
|
84
|
+
|
|
85
|
+
for (const raw of lines) {
|
|
86
|
+
const line = raw.trim();
|
|
87
|
+
if (!line) continue;
|
|
88
|
+
if (line === "WEBVTT") continue;
|
|
89
|
+
if (/^\d{2}:\d{2}:\d{2}\.\d{3} -->/.test(line)) continue;
|
|
90
|
+
const text = line.replace(/<[^>]+>/g, "").trim();
|
|
91
|
+
if (!text) continue;
|
|
92
|
+
if (text === prev) continue;
|
|
93
|
+
prev = text;
|
|
94
|
+
cleaned.push(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return cleaned.join(" ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readFileIfExists(filePath: string): Promise<string | null> {
|
|
101
|
+
try {
|
|
102
|
+
return await Bun.file(filePath).text();
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function findVttFile(tmpdir: string): Promise<string | null> {
|
|
109
|
+
const glob = new Bun.Glob("video*.vtt");
|
|
110
|
+
for await (const file of glob.scan(tmpdir)) {
|
|
111
|
+
return path.join(tmpdir, file);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const analyzeYoutube = tool({
|
|
117
|
+
description:
|
|
118
|
+
"Analyze a YouTube video by extracting its subtitles and metadata without downloading the video. " +
|
|
119
|
+
"Use this tool whenever the user shares a YouTube URL",
|
|
120
|
+
inputSchema: z.object({
|
|
121
|
+
url: z.string().describe("YouTube video URL"),
|
|
122
|
+
question: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Question to answer about the video. Omit for a general summary."),
|
|
126
|
+
}),
|
|
127
|
+
execute: async ({ url, question }) => {
|
|
128
|
+
const tmpdir = path.join(os.tmpdir(), `u1z-yt-${Math.random().toString(36).slice(2)}`);
|
|
129
|
+
await mkdir(tmpdir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// Pass 1: metadata only
|
|
133
|
+
const { stderr: infoStderr, exitCode: infoExit } = await spawnYtDlp([
|
|
134
|
+
"--skip-download",
|
|
135
|
+
"--write-info-json",
|
|
136
|
+
"-o",
|
|
137
|
+
path.join(tmpdir, "video"),
|
|
138
|
+
url,
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
if (infoExit !== 0) {
|
|
142
|
+
if (infoStderr.includes("command not found") || infoStderr.includes("No such file")) {
|
|
143
|
+
throw new Error("yt-dlp not found. Please install yt-dlp.");
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`yt-dlp failed: ${infoStderr}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const infoJson = await readFileIfExists(path.join(tmpdir, "video.info.json"));
|
|
149
|
+
let meta = {
|
|
150
|
+
title: "",
|
|
151
|
+
uploader: "",
|
|
152
|
+
upload_date: "",
|
|
153
|
+
view_count: 0,
|
|
154
|
+
description: "",
|
|
155
|
+
tags: [] as string[],
|
|
156
|
+
};
|
|
157
|
+
let subtitleChoice: { lang: string; auto: boolean } | null = null;
|
|
158
|
+
|
|
159
|
+
if (infoJson) {
|
|
160
|
+
const parsed = YtInfoSchema.parse(JSON.parse(infoJson));
|
|
161
|
+
meta = {
|
|
162
|
+
title: parsed.title,
|
|
163
|
+
uploader: parsed.uploader,
|
|
164
|
+
upload_date: parsed.upload_date,
|
|
165
|
+
view_count: parsed.view_count,
|
|
166
|
+
description: parsed.description.slice(0, 500),
|
|
167
|
+
tags: parsed.tags.slice(0, 20),
|
|
168
|
+
};
|
|
169
|
+
subtitleChoice = pickSubtitle(
|
|
170
|
+
parsed.subtitles,
|
|
171
|
+
parsed.automatic_captions,
|
|
172
|
+
parsed.language ?? undefined,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pass 2: download chosen subtitle
|
|
177
|
+
// 실패 시 조용히 (자막 없음)으로 fallback — 메타데이터만으로도 분석을 계속한다
|
|
178
|
+
let transcript = "(자막 없음)";
|
|
179
|
+
if (subtitleChoice) {
|
|
180
|
+
const subFlag = subtitleChoice.auto ? "--write-auto-subs" : "--write-subs";
|
|
181
|
+
await spawnYtDlp([
|
|
182
|
+
"--skip-download",
|
|
183
|
+
subFlag,
|
|
184
|
+
"--sub-langs",
|
|
185
|
+
subtitleChoice.lang,
|
|
186
|
+
"--sub-format",
|
|
187
|
+
"vtt",
|
|
188
|
+
"-o",
|
|
189
|
+
path.join(tmpdir, "video"),
|
|
190
|
+
url,
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const vttPath = await findVttFile(tmpdir);
|
|
194
|
+
if (vttPath) {
|
|
195
|
+
const vttContent = await readFileIfExists(vttPath);
|
|
196
|
+
if (vttContent) transcript = parseVtt(vttContent) || "(자막 없음)";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const prompt = `
|
|
201
|
+
<video>
|
|
202
|
+
<metadata>
|
|
203
|
+
<title>${meta.title}</title>
|
|
204
|
+
<channel>${meta.uploader}</channel>
|
|
205
|
+
<upload_date>${meta.upload_date}</upload_date>
|
|
206
|
+
<view_count>${meta.view_count}</view_count>
|
|
207
|
+
<description>${meta.description}</description>
|
|
208
|
+
<tags>${meta.tags.join(", ")}</tags>
|
|
209
|
+
</metadata>
|
|
210
|
+
<transcript>${transcript}</transcript>
|
|
211
|
+
</video>
|
|
212
|
+
|
|
213
|
+
<question>${question ?? "위 영상 내용을 한국어로 요약해줘."}</question>
|
|
214
|
+
`.trim();
|
|
215
|
+
|
|
216
|
+
const { text } = await generateText({
|
|
217
|
+
model: MODELS.sm,
|
|
218
|
+
messages: [{ role: "user", content: prompt }],
|
|
219
|
+
maxOutputTokens: 2048,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return text;
|
|
223
|
+
} finally {
|
|
224
|
+
await rm(tmpdir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { executeBash } from "./bash";
|
|
3
|
+
|
|
4
|
+
describe("bash tool", () => {
|
|
5
|
+
test("executes command and returns stdout", async () => {
|
|
6
|
+
const result = await executeBash({ command: "echo hello" });
|
|
7
|
+
expect(result.stdout).toBe("hello");
|
|
8
|
+
expect(result.exitCode).toBe(0);
|
|
9
|
+
expect(result.stderr).toBe("");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("returns non-zero exit code for failed command", async () => {
|
|
13
|
+
const result = await executeBash({ command: "ls /nonexistent-path-xyz-u1z" });
|
|
14
|
+
expect(result.exitCode).not.toBe(0);
|
|
15
|
+
expect(result.stderr).not.toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("captures both stdout and stderr", async () => {
|
|
19
|
+
const result = await executeBash({ command: "echo out && echo err >&2" });
|
|
20
|
+
expect(result.stdout).toBe("out");
|
|
21
|
+
expect(result.stderr).toBe("err");
|
|
22
|
+
expect(result.exitCode).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export async function executeBash({ command }: { command: string }) {
|
|
5
|
+
const proc = Bun.spawn(["sh", "-c", command], { stdout: "pipe", stderr: "pipe" });
|
|
6
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
7
|
+
new Response(proc.stdout).text(),
|
|
8
|
+
new Response(proc.stderr).text(),
|
|
9
|
+
proc.exited,
|
|
10
|
+
]);
|
|
11
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const bash = tool({
|
|
15
|
+
description: "Execute a shell command and return its stdout, stderr, and exit code.",
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
command: z.string().describe("Shell command to execute"),
|
|
18
|
+
}),
|
|
19
|
+
execute: executeBash,
|
|
20
|
+
});
|