companionbot 0.5.0 → 0.6.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.
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Sub-agent 시스템 - 복잡한 작업을 독립적인 agent에게 위임
3
3
  */
4
- export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent, abortAllAgents, stopCleanup } from "./manager.js";
4
+ export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent } from "./manager.js";
@@ -198,14 +198,6 @@ export function cleanupOldAgents() {
198
198
  // running 상태도 1시간 지나면 정리 (stuck agent 방지)
199
199
  if (agent.status === "running" && agent.createdAt.getTime() < oneHourAgo) {
200
200
  console.log(`[Agent ${id}] Cleaning up stuck agent (running > 1h)`);
201
- // 실행 중인 API 호출 취소
202
- const controller = abortControllers.get(id);
203
- if (controller) {
204
- controller.abort();
205
- abortControllers.delete(id);
206
- }
207
- agent.status = "cancelled";
208
- agent.completedAt = new Date();
209
201
  agents.delete(id);
210
202
  }
211
203
  }
@@ -231,27 +223,5 @@ export function stopCleanup() {
231
223
  console.log("[AgentManager] Cleanup interval stopped");
232
224
  }
233
225
  }
234
- /**
235
- * 모든 진행 중인 agent abort 및 정리 (shutdown 시 사용)
236
- */
237
- export function abortAllAgents() {
238
- console.log("[AgentManager] Aborting all running agents...");
239
- // 모든 AbortController abort
240
- for (const [id, controller] of abortControllers) {
241
- console.log(`[Agent ${id}] Aborting`);
242
- controller.abort();
243
- }
244
- abortControllers.clear();
245
- // 모든 running agent 상태 업데이트
246
- for (const [id, agent] of agents) {
247
- if (agent.status === "running") {
248
- agent.status = "cancelled";
249
- agent.completedAt = new Date();
250
- }
251
- }
252
- // cleanup interval 중지
253
- stopCleanup();
254
- console.log("[AgentManager] All agents aborted");
255
- }
256
226
  // 자동 시작
257
227
  startCleanup();
package/dist/ai/claude.js CHANGED
@@ -7,29 +7,59 @@ function getClient() {
7
7
  }
8
8
  return anthropic;
9
9
  }
10
+ // 모델별 max_tokens 및 thinking budget 설정
11
+ // 참고: Claude API에서 thinking + output이 모델 한도 초과하면 안 됨
10
12
  export const MODELS = {
11
- sonnet: { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
12
- opus: { id: "claude-opus-4-20250514", name: "Claude Opus 4" },
13
- haiku: { id: "claude-haiku-3-5-20241022", name: "Claude Haiku 3.5" },
13
+ haiku: {
14
+ id: "claude-haiku-3-5-20241022",
15
+ name: "Claude Haiku 3.5",
16
+ maxTokens: 4096, // 빠른 응답
17
+ thinkingBudget: 0, // Haiku는 thinking 미지원
18
+ },
19
+ sonnet: {
20
+ id: "claude-sonnet-4-20250514",
21
+ name: "Claude Sonnet 4",
22
+ maxTokens: 8192, // 일반 작업
23
+ thinkingBudget: 10000, // 적당한 thinking
24
+ },
25
+ opus: {
26
+ id: "claude-opus-4-20250514",
27
+ name: "Claude Opus 4",
28
+ maxTokens: 16384, // 복잡한 작업
29
+ thinkingBudget: 32000, // 깊은 thinking
30
+ },
14
31
  };
15
- export async function chat(messages, systemPrompt, modelId = "sonnet", options) {
32
+ export async function chat(messages, systemPrompt, modelId = "sonnet") {
16
33
  const client = getClient();
17
- const model = MODELS[modelId].id;
18
- const signal = options?.signal;
34
+ const modelConfig = MODELS[modelId];
19
35
  // 메시지를 API 형식으로 변환
20
36
  const apiMessages = messages.map((m) => ({
21
37
  role: m.role,
22
38
  content: m.content,
23
39
  }));
24
- let response;
25
- try {
26
- response = await client.messages.create({
27
- model,
28
- max_tokens: 4096,
29
- system: systemPrompt,
40
+ // API 요청 파라미터 빌드 (도구 루프에서도 동일하게 사용)
41
+ const buildRequestParams = () => {
42
+ const params = {
43
+ model: modelConfig.id,
44
+ max_tokens: modelConfig.maxTokens,
30
45
  messages: apiMessages,
31
46
  tools: tools,
32
- }, { signal });
47
+ };
48
+ if (systemPrompt) {
49
+ params.system = systemPrompt;
50
+ }
51
+ // thinking 활성화 (budget > 0인 경우)
52
+ if (modelConfig.thinkingBudget > 0) {
53
+ params.thinking = {
54
+ type: "enabled",
55
+ budget_tokens: modelConfig.thinkingBudget,
56
+ };
57
+ }
58
+ return params;
59
+ };
60
+ let response;
61
+ try {
62
+ response = await client.messages.create(buildRequestParams());
33
63
  }
34
64
  catch (error) {
35
65
  if (error instanceof Anthropic.APIError) {
@@ -72,15 +102,9 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
72
102
  role: "user",
73
103
  content: toolResults,
74
104
  });
75
- // 다음 응답 요청
105
+ // 다음 응답 요청 (도구 루프에서도 thinking 유지)
76
106
  try {
77
- response = await client.messages.create({
78
- model,
79
- max_tokens: 4096,
80
- system: systemPrompt,
81
- messages: apiMessages,
82
- tools: tools,
83
- }, { signal });
107
+ response = await client.messages.create(buildRequestParams());
84
108
  }
85
109
  catch (error) {
86
110
  if (error instanceof Anthropic.APIError) {
@@ -103,3 +127,90 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
103
127
  const textBlock = response.content.find((block) => block.type === "text");
104
128
  return textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?";
105
129
  }
130
+ /**
131
+ * 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
132
+ *
133
+ * 전략:
134
+ * - 먼저 스트리밍으로 시도
135
+ * - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
136
+ * - 스트리밍은 최종 텍스트 응답에만 사용
137
+ */
138
+ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
139
+ // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
140
+ if (!onChunk) {
141
+ const text = await chat(messages, systemPrompt, modelId);
142
+ return { text, usedTools: false };
143
+ }
144
+ const client = getClient();
145
+ const modelConfig = MODELS[modelId];
146
+ // 메시지를 API 형식으로 변환
147
+ const apiMessages = messages.map((m) => ({
148
+ role: m.role,
149
+ content: m.content,
150
+ }));
151
+ // 스트리밍 요청 파라미터
152
+ const params = {
153
+ model: modelConfig.id,
154
+ max_tokens: modelConfig.maxTokens,
155
+ messages: apiMessages,
156
+ tools: tools,
157
+ stream: true,
158
+ };
159
+ if (systemPrompt) {
160
+ params.system = systemPrompt;
161
+ }
162
+ // Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
163
+ // (도구 호출 폴백 시 chat()에서 thinking 사용됨)
164
+ let accumulated = "";
165
+ let stopReason = null;
166
+ try {
167
+ const stream = client.messages.stream(params);
168
+ // 스트리밍 이벤트 처리
169
+ stream.on("text", async (text) => {
170
+ accumulated += text;
171
+ try {
172
+ await onChunk(text, accumulated);
173
+ }
174
+ catch (err) {
175
+ // editMessageText 실패 등은 무시하고 계속
176
+ console.warn("[Stream] Chunk callback error (ignored):", err);
177
+ }
178
+ });
179
+ // 스트림 완료 대기
180
+ const finalMessage = await stream.finalMessage();
181
+ stopReason = finalMessage.stop_reason;
182
+ // 도구 호출이 필요한 경우 - 일반 chat으로 폴백
183
+ if (stopReason === "tool_use") {
184
+ console.log("[Stream] Tool use detected, falling back to chat()");
185
+ const text = await chat(messages, systemPrompt, modelId);
186
+ return { text, usedTools: true };
187
+ }
188
+ // 성공적으로 스트리밍 완료
189
+ return { text: accumulated, usedTools: false };
190
+ }
191
+ catch (error) {
192
+ // 스트리밍 에러 핸들링
193
+ if (error instanceof Anthropic.APIError) {
194
+ if (error.status === 429) {
195
+ throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
196
+ }
197
+ if (error.status >= 500) {
198
+ throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
199
+ }
200
+ }
201
+ // 연결 끊김 등 스트리밍 에러 - 이미 받은 내용이 있으면 반환 시도
202
+ if (accumulated.length > 50) {
203
+ console.warn("[Stream] Connection error, returning partial response");
204
+ return { text: accumulated + "\n\n(연결이 끊겨서 응답이 잘렸을 수 있어)", usedTools: false };
205
+ }
206
+ // 응답이 거의 없으면 일반 chat으로 재시도
207
+ console.warn("[Stream] Connection error, retrying with chat()");
208
+ try {
209
+ const text = await chat(messages, systemPrompt, modelId);
210
+ return { text, usedTools: false };
211
+ }
212
+ catch (retryError) {
213
+ throw retryError;
214
+ }
215
+ }
216
+ }
@@ -8,7 +8,7 @@ export { isValidCronExpression, parseCronExpression, getNextCronRun, getNextRun,
8
8
  // Storage functions
9
9
  export { loadJobs, saveJobs, addJob, removeJob, updateJob, getDueJobs, markJobExecuted, getJobsByChat, getJob, calculateNextRun, } from "./store.js";
10
10
  // Scheduler functions
11
- export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, } from "./scheduler.js";
11
+ export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, ensureDefaultCronJobs, } from "./scheduler.js";
12
12
  // Command handlers (for tools)
13
13
  export { addCronJob, removeCronJob, setCronJobEnabled, listCronJobs, getCronStatus, } from "./commands.js";
14
14
  // Aliases for backward compatibility
@@ -130,163 +130,30 @@ export function parseCronExpression(expr) {
130
130
  };
131
131
  }
132
132
  /**
133
- * Get time components in a specific timezone using Intl API
134
- */
135
- function getTimeInTimezone(date, timezone) {
136
- try {
137
- const formatter = new Intl.DateTimeFormat("en-US", {
138
- timeZone: timezone,
139
- year: "numeric",
140
- month: "2-digit",
141
- day: "2-digit",
142
- hour: "2-digit",
143
- minute: "2-digit",
144
- weekday: "short",
145
- hour12: false,
146
- });
147
- const parts = formatter.formatToParts(date);
148
- const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
149
- const dayOfWeekMap = {
150
- Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
151
- };
152
- return {
153
- year: parseInt(getValue("year"), 10),
154
- month: parseInt(getValue("month"), 10),
155
- day: parseInt(getValue("day"), 10),
156
- hour: parseInt(getValue("hour"), 10),
157
- minute: parseInt(getValue("minute"), 10),
158
- dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
159
- };
160
- }
161
- catch {
162
- // Fallback to local time if timezone is invalid
163
- return {
164
- year: date.getFullYear(),
165
- month: date.getMonth() + 1,
166
- day: date.getDate(),
167
- hour: date.getHours(),
168
- minute: date.getMinutes(),
169
- dayOfWeek: date.getDay(),
170
- };
171
- }
172
- }
173
- /**
174
- * Create a Date object for a specific time in a timezone
175
- */
176
- function createDateInTimezone(year, month, day, hour, minute, timezone) {
177
- try {
178
- const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
179
- const formatter = new Intl.DateTimeFormat("en-US", {
180
- timeZone: timezone,
181
- year: "numeric",
182
- month: "2-digit",
183
- day: "2-digit",
184
- hour: "2-digit",
185
- minute: "2-digit",
186
- hour12: false,
187
- });
188
- // Binary search for the correct UTC time
189
- let guess = new Date(dateStr);
190
- for (let i = 0; i < 3; i++) {
191
- const parts = formatter.formatToParts(guess);
192
- const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
193
- const guessHour = getValue("hour");
194
- const guessMinute = getValue("minute");
195
- const guessDay = getValue("day");
196
- let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
197
- if (day !== guessDay) {
198
- if (day > guessDay || (day === 1 && guessDay > 20)) {
199
- diffMinutes += 24 * 60;
200
- }
201
- else {
202
- diffMinutes -= 24 * 60;
203
- }
204
- }
205
- if (diffMinutes === 0)
206
- break;
207
- guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
208
- }
209
- return guess;
210
- }
211
- catch {
212
- return new Date(year, month - 1, day, hour, minute, 0, 0);
213
- }
214
- }
215
- /**
216
- * Get number of days in a month
217
- */
218
- function getDaysInMonth(year, month) {
219
- return new Date(year, month, 0).getDate();
220
- }
221
- /**
222
- * Calculate the next run time for a cron expression with timezone support
133
+ * Calculate the next run time for a cron expression
223
134
  */
224
135
  export function getNextCronRun(expression, fromDate = new Date(), timezone) {
225
136
  const parsed = parseCronExpression(expression);
226
- const now = fromDate;
227
- const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
228
- // Get current time in target timezone
229
- const current = getTimeInTimezone(now, tz);
230
- // Search for next valid time (up to 366 days ahead)
231
- const searchDate = { ...current };
232
- const maxIterations = 366 * 24 * 60;
137
+ const from = new Date(fromDate);
138
+ // Start from the next minute
139
+ from.setSeconds(0);
140
+ from.setMilliseconds(0);
141
+ from.setMinutes(from.getMinutes() + 1);
142
+ // Search for the next matching time (max 2 years ahead)
143
+ const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
233
144
  for (let i = 0; i < maxIterations; i++) {
234
- // Advance by one minute each iteration
235
- if (i > 0) {
236
- searchDate.minute++;
237
- if (searchDate.minute > 59) {
238
- searchDate.minute = 0;
239
- searchDate.hour++;
240
- if (searchDate.hour > 23) {
241
- searchDate.hour = 0;
242
- searchDate.day++;
243
- searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
244
- const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
245
- if (searchDate.day > daysInMonth) {
246
- searchDate.day = 1;
247
- searchDate.month++;
248
- if (searchDate.month > 12) {
249
- searchDate.month = 1;
250
- searchDate.year++;
251
- }
252
- }
253
- }
254
- }
255
- }
256
- // Check if this time matches all constraints
257
- if (!parsed.month.values.includes(searchDate.month))
258
- continue;
259
- // For dayOfMonth and dayOfWeek: if both are restricted, match either (OR logic)
260
- // If only one is restricted, use that one
261
- const domRestricted = parsed.dayOfMonth.type !== "wildcard";
262
- const dowRestricted = parsed.dayOfWeek.type !== "wildcard";
263
- if (domRestricted && dowRestricted) {
264
- // Both restricted: match either
265
- if (!parsed.dayOfMonth.values.includes(searchDate.day) &&
266
- !parsed.dayOfWeek.values.includes(searchDate.dayOfWeek)) {
267
- continue;
268
- }
269
- }
270
- else if (domRestricted) {
271
- if (!parsed.dayOfMonth.values.includes(searchDate.day))
272
- continue;
273
- }
274
- else if (dowRestricted) {
275
- if (!parsed.dayOfWeek.values.includes(searchDate.dayOfWeek))
276
- continue;
277
- }
278
- if (!parsed.hour.values.includes(searchDate.hour))
279
- continue;
280
- if (!parsed.minute.values.includes(searchDate.minute))
281
- continue;
282
- // Skip if this is the current minute (must be in the future)
283
- if (i === 0)
284
- continue;
285
- // Create the date in the target timezone
286
- const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
287
- // Verify it's in the future
288
- if (nextRun > now) {
289
- return nextRun;
145
+ const candidate = new Date(from.getTime() + i * 60000);
146
+ const minute = candidate.getMinutes();
147
+ const hour = candidate.getHours();
148
+ const dayOfMonth = candidate.getDate();
149
+ const month = candidate.getMonth() + 1;
150
+ const dayOfWeek = candidate.getDay();
151
+ if (parsed.minute.values.includes(minute) &&
152
+ parsed.hour.values.includes(hour) &&
153
+ parsed.month.values.includes(month) &&
154
+ (parsed.dayOfMonth.values.includes(dayOfMonth) ||
155
+ parsed.dayOfWeek.values.includes(dayOfWeek))) {
156
+ return candidate;
290
157
  }
291
158
  }
292
159
  throw new Error(`Could not find next run time for: ${expression}`);
@@ -7,7 +7,6 @@
7
7
  import { getDueJobs, markJobExecuted, loadJobs, addJob, removeJob, updateJob, getJobsByChat } from "./store.js";
8
8
  import { chat } from "../ai/claude.js";
9
9
  import { buildSystemPrompt } from "../telegram/utils/prompt.js";
10
- import { runWithChatId } from "../session/state.js";
11
10
  // Scheduler state
12
11
  let schedulerInterval = null;
13
12
  let botInstance = null;
@@ -146,8 +145,7 @@ async function executeSystemEvent(job, payload, bot) {
146
145
  */
147
146
  async function executeAgentTurn(job, payload, bot) {
148
147
  const { message: inputMessage, context } = payload;
149
- // Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
150
- const response = await runWithChatId(job.chatId, async () => {
148
+ try {
151
149
  // Build a fresh conversation for this job (separate from main chat)
152
150
  const messages = [
153
151
  {
@@ -167,10 +165,8 @@ async function executeAgentTurn(job, payload, bot) {
167
165
  - Run Count: ${(job.runCount || 0) + 1}
168
166
  - This is a scheduled task, not a direct user message.`;
169
167
  // Call Claude API
170
- return await chat(messages, systemPrompt, "sonnet");
171
- });
172
- // Send the response to the chat
173
- try {
168
+ const response = await chat(messages, systemPrompt, "sonnet");
169
+ // Send the response to the chat
174
170
  if (response && response.trim()) {
175
171
  // Split long messages (Telegram limit is 4096 characters)
176
172
  const maxLength = 4000;
@@ -328,3 +324,34 @@ export function getActiveJobCount() {
328
324
  // For actual count, use getAllCronJobs and filter
329
325
  return 0; // Placeholder - will be updated by scheduler
330
326
  }
327
+ // ============================================================
328
+ // Default Cron Jobs
329
+ // ============================================================
330
+ const DEFAULT_CRON_JOBS = [
331
+ {
332
+ name: "daily_memory_save",
333
+ cronExpr: "0 12 * * *", // 매일 12시
334
+ command: "오늘 하루 동안 있었던 중요한 일들을 정리해서 MEMORY.md에 저장해줘. 새로운 정보, 대화 내용, 배운 것들 위주로.",
335
+ timezone: "Asia/Seoul",
336
+ },
337
+ ];
338
+ /**
339
+ * Ensure default cron jobs exist for a chat
340
+ * Call this after onboarding or on /start
341
+ */
342
+ export async function ensureDefaultCronJobs(chatId) {
343
+ const existingJobs = await getJobsByChat(chatId);
344
+ for (const defaultJob of DEFAULT_CRON_JOBS) {
345
+ const exists = existingJobs.some(job => job.name === defaultJob.name);
346
+ if (!exists) {
347
+ await createCronJob({
348
+ chatId,
349
+ name: defaultJob.name,
350
+ cronExpr: defaultJob.cronExpr,
351
+ command: defaultJob.command,
352
+ timezone: defaultJob.timezone,
353
+ });
354
+ console.log(`[Cron] Added default job: ${defaultJob.name} for chat ${chatId}`);
355
+ }
356
+ }
357
+ }
@@ -4,7 +4,6 @@ import { getWorkspacePath } from "../workspace/index.js";
4
4
  import { chat } from "../ai/claude.js";
5
5
  import { isCalendarConfigured, getTodayEvents, formatEvent } from "../calendar/index.js";
6
6
  import { getSecret } from "../config/secrets.js";
7
- import { runWithChatId } from "../session/state.js";
8
7
  // 활성 타이머
9
8
  const activeTimers = new Map();
10
9
  // 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
@@ -121,10 +120,7 @@ ${context}
121
120
  ];
122
121
  let messageSent = false;
123
122
  try {
124
- // Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
125
- const response = await runWithChatId(config.chatId, async () => {
126
- return await chat(messages, systemPrompt, "haiku");
127
- });
123
+ const response = await chat(messages, systemPrompt, "haiku");
128
124
  if (!response.trim().includes("HEARTBEAT_OK")) {
129
125
  await botInstance.api.sendMessage(config.chatId, response);
130
126
  console.log(`[Heartbeat] Sent message to ${config.chatId}`);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 로컬 임베딩 생성 모듈
3
+ * @xenova/transformers를 사용하여 텍스트 임베딩을 생성합니다.
4
+ */
5
+ import { pipeline } from "@xenova/transformers";
6
+ // 싱글톤 파이프라인
7
+ let embeddingPipeline = null;
8
+ // 모델 로딩 중인지 추적
9
+ let isLoading = false;
10
+ let loadingPromise = null;
11
+ /**
12
+ * 임베딩 파이프라인을 초기화합니다.
13
+ * 작고 빠른 모델 사용 (384 차원)
14
+ */
15
+ async function getEmbeddingPipeline() {
16
+ if (embeddingPipeline) {
17
+ return embeddingPipeline;
18
+ }
19
+ // 이미 로딩 중이면 기다림
20
+ if (isLoading && loadingPromise) {
21
+ return loadingPromise;
22
+ }
23
+ isLoading = true;
24
+ loadingPromise = pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2" // 384차원, 빠르고 가벼움
25
+ );
26
+ try {
27
+ embeddingPipeline = await loadingPromise;
28
+ return embeddingPipeline;
29
+ }
30
+ finally {
31
+ isLoading = false;
32
+ }
33
+ }
34
+ /**
35
+ * 텍스트를 임베딩 벡터로 변환합니다.
36
+ * @param text 변환할 텍스트
37
+ * @returns 384차원 임베딩 벡터
38
+ */
39
+ export async function embed(text) {
40
+ const pipe = await getEmbeddingPipeline();
41
+ // 텍스트 정규화
42
+ const cleanText = text.trim().slice(0, 512); // 최대 512자
43
+ if (!cleanText) {
44
+ return new Array(384).fill(0);
45
+ }
46
+ const result = await pipe(cleanText, {
47
+ pooling: "mean",
48
+ normalize: true,
49
+ });
50
+ // Tensor를 배열로 변환
51
+ return Array.from(result.data);
52
+ }
53
+ /**
54
+ * 여러 텍스트를 배치로 임베딩합니다.
55
+ * @param texts 변환할 텍스트 배열
56
+ * @returns 임베딩 벡터 배열
57
+ */
58
+ export async function embedBatch(texts) {
59
+ const results = [];
60
+ for (const text of texts) {
61
+ results.push(await embed(text));
62
+ }
63
+ return results;
64
+ }
65
+ /**
66
+ * 두 벡터 간의 코사인 유사도를 계산합니다.
67
+ *
68
+ * 최적화: embed()에서 normalize: true로 정규화된 벡터를 반환하므로,
69
+ * 정규화된 벡터의 경우 코사인 유사도 = 내적 (norm이 1이므로)
70
+ * normalized 파라미터가 true면 내적만 계산하여 성능 향상.
71
+ */
72
+ export function cosineSimilarity(a, b, normalized = true) {
73
+ if (a.length !== b.length)
74
+ return 0;
75
+ let dotProduct = 0;
76
+ for (let i = 0; i < a.length; i++) {
77
+ dotProduct += a[i] * b[i];
78
+ }
79
+ // 정규화된 벡터면 내적 = 코사인 유사도
80
+ if (normalized) {
81
+ return dotProduct;
82
+ }
83
+ // 정규화되지 않은 벡터면 norm 계산 필요
84
+ let normA = 0;
85
+ let normB = 0;
86
+ for (let i = 0; i < a.length; i++) {
87
+ normA += a[i] * a[i];
88
+ normB += b[i] * b[i];
89
+ }
90
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
91
+ if (denominator === 0)
92
+ return 0;
93
+ return dotProduct / denominator;
94
+ }
@@ -0,0 +1,4 @@
1
+ // Memory module exports
2
+ export { embed, embedBatch, cosineSimilarity } from './embeddings.js';
3
+ export { search, invalidateCache } from './vectorStore.js';
4
+ export { indexFile, indexMainMemory, indexDailyMemories, reindexAll } from './indexer.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 메모리 인덱서 모듈
3
+ * 현재 구현은 vectorStore가 on-demand로 로드하므로 캐시 무효화만 수행
4
+ */
5
+ import { invalidateCache } from './vectorStore.js';
6
+ // 단일 파일 인덱싱 (캐시 무효화)
7
+ export async function indexFile(_filePath, _source) {
8
+ // vectorStore가 on-demand로 로드하므로 캐시만 무효화
9
+ invalidateCache();
10
+ return 1;
11
+ }
12
+ // MEMORY.md 인덱싱
13
+ export async function indexMainMemory() {
14
+ invalidateCache();
15
+ return 1;
16
+ }
17
+ // 일일 메모리 파일들 인덱싱
18
+ export async function indexDailyMemories(_days = 30) {
19
+ invalidateCache();
20
+ return 1;
21
+ }
22
+ // 전체 리인덱싱 (캐시 무효화 후 미리 로드)
23
+ export async function reindexAll() {
24
+ console.log('[Indexer] Invalidating cache for reindex...');
25
+ invalidateCache();
26
+ // 캐시 무효화 후 즉시 로드하여 청크 수 반환
27
+ // search를 임시로 호출하여 로드 트리거 (빈 쿼리로)
28
+ const { loadAllMemoryChunks } = await import('./vectorStore.js');
29
+ const chunks = await loadAllMemoryChunks();
30
+ // 소스별 집계
31
+ const sourceCounts = new Map();
32
+ for (const chunk of chunks) {
33
+ sourceCounts.set(chunk.source, (sourceCounts.get(chunk.source) || 0) + 1);
34
+ }
35
+ return {
36
+ total: chunks.length,
37
+ sources: Array.from(sourceCounts.keys())
38
+ };
39
+ }