companionbot 0.5.0 → 0.7.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.
@@ -7,7 +7,8 @@
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";
10
+ import { INTERVAL_1_MINUTE } from "../utils/time.js";
11
+ import { TELEGRAM_SAFE_LIMIT } from "../utils/constants.js";
11
12
  // Scheduler state
12
13
  let schedulerInterval = null;
13
14
  let botInstance = null;
@@ -37,7 +38,7 @@ export class CronScheduler {
37
38
  // Then check every minute
38
39
  this.interval = setInterval(() => {
39
40
  this.checkAndRun().catch((err) => console.error("[CronScheduler] Check failed:", err));
40
- }, 60 * 1000); // 1 minute
41
+ }, INTERVAL_1_MINUTE);
41
42
  console.log("[CronScheduler] Started - checking every minute");
42
43
  }
43
44
  /**
@@ -146,8 +147,7 @@ async function executeSystemEvent(job, payload, bot) {
146
147
  */
147
148
  async function executeAgentTurn(job, payload, bot) {
148
149
  const { message: inputMessage, context } = payload;
149
- // Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
150
- const response = await runWithChatId(job.chatId, async () => {
150
+ try {
151
151
  // Build a fresh conversation for this job (separate from main chat)
152
152
  const messages = [
153
153
  {
@@ -167,25 +167,26 @@ async function executeAgentTurn(job, payload, bot) {
167
167
  - Run Count: ${(job.runCount || 0) + 1}
168
168
  - This is a scheduled task, not a direct user message.`;
169
169
  // Call Claude API
170
- return await chat(messages, systemPrompt, "sonnet");
171
- });
172
- // Send the response to the chat
173
- try {
174
- if (response && response.trim()) {
170
+ const response = await chat(messages, systemPrompt, "sonnet");
171
+ // Send the response to the chat
172
+ const trimmedResponse = response?.trim();
173
+ if (trimmedResponse) {
175
174
  // Split long messages (Telegram limit is 4096 characters)
176
- const maxLength = 4000;
177
- if (response.length <= maxLength) {
178
- await bot.api.sendMessage(job.chatId, response, {
175
+ const maxLength = TELEGRAM_SAFE_LIMIT;
176
+ if (trimmedResponse.length <= maxLength) {
177
+ await bot.api.sendMessage(job.chatId, trimmedResponse, {
179
178
  parse_mode: "Markdown",
180
179
  });
181
180
  }
182
181
  else {
183
182
  // Split into multiple messages
184
- const chunks = splitMessage(response, maxLength);
183
+ const chunks = splitMessage(trimmedResponse, maxLength);
185
184
  for (const chunk of chunks) {
186
- await bot.api.sendMessage(job.chatId, chunk, {
187
- parse_mode: "Markdown",
188
- });
185
+ if (chunk) {
186
+ await bot.api.sendMessage(job.chatId, chunk, {
187
+ parse_mode: "Markdown",
188
+ });
189
+ }
189
190
  }
190
191
  }
191
192
  }
@@ -206,6 +207,10 @@ async function executeAgentTurn(job, payload, bot) {
206
207
  * Split a long message into chunks
207
208
  */
208
209
  function splitMessage(text, maxLength) {
210
+ // null/undefined/빈 문자열 처리
211
+ if (!text || text.trim().length === 0) {
212
+ return [];
213
+ }
209
214
  const chunks = [];
210
215
  let remaining = text;
211
216
  while (remaining.length > 0) {
@@ -328,3 +333,34 @@ export function getActiveJobCount() {
328
333
  // For actual count, use getAllCronJobs and filter
329
334
  return 0; // Placeholder - will be updated by scheduler
330
335
  }
336
+ // ============================================================
337
+ // Default Cron Jobs
338
+ // ============================================================
339
+ const DEFAULT_CRON_JOBS = [
340
+ {
341
+ name: "daily_memory_save",
342
+ cronExpr: "0 12 * * *", // 매일 12시
343
+ command: "오늘 하루 동안 있었던 중요한 일들을 정리해서 MEMORY.md에 저장해줘. 새로운 정보, 대화 내용, 배운 것들 위주로.",
344
+ timezone: "Asia/Seoul",
345
+ },
346
+ ];
347
+ /**
348
+ * Ensure default cron jobs exist for a chat
349
+ * Call this after onboarding or on /start
350
+ */
351
+ export async function ensureDefaultCronJobs(chatId) {
352
+ const existingJobs = await getJobsByChat(chatId);
353
+ for (const defaultJob of DEFAULT_CRON_JOBS) {
354
+ const exists = existingJobs.some(job => job.name === defaultJob.name);
355
+ if (!exists) {
356
+ await createCronJob({
357
+ chatId,
358
+ name: defaultJob.name,
359
+ cronExpr: defaultJob.cronExpr,
360
+ command: defaultJob.command,
361
+ timezone: defaultJob.timezone,
362
+ });
363
+ console.log(`[Cron] Added default job: ${defaultJob.name} for chat ${chatId}`);
364
+ }
365
+ }
366
+ }
@@ -6,13 +6,11 @@
6
6
  import * as fs from "fs/promises";
7
7
  import * as path from "path";
8
8
  import { getWorkspacePath } from "../workspace/paths.js";
9
+ import { sleep } from "../utils/time.js";
10
+ import { LOCK_TIMEOUT_MS, LOCK_RETRY_MS, LOCK_MAX_RETRIES } from "../utils/constants.js";
9
11
  const CRON_FILE = "cron-jobs.json";
10
12
  const LOCK_FILE = "cron-jobs.lock";
11
13
  const STORE_VERSION = 1;
12
- // Lock configuration
13
- const LOCK_TIMEOUT_MS = 5000;
14
- const LOCK_RETRY_MS = 50;
15
- const LOCK_MAX_RETRIES = 100;
16
14
  function getCronFilePath() {
17
15
  return path.join(getWorkspacePath(), CRON_FILE);
18
16
  }
@@ -73,9 +71,6 @@ async function releaseLock() {
73
71
  // Ignore errors on unlock
74
72
  }
75
73
  }
76
- function sleep(ms) {
77
- return new Promise((resolve) => setTimeout(resolve, ms));
78
- }
79
74
  /**
80
75
  * Execute a function with file lock protection
81
76
  */
@@ -94,13 +89,39 @@ async function withLock(fn) {
94
89
  async function loadJobsInternal() {
95
90
  try {
96
91
  const data = await fs.readFile(getCronFilePath(), "utf-8");
92
+ // 빈 파일 체크
93
+ if (!data || data.trim() === "") {
94
+ return [];
95
+ }
97
96
  const store = JSON.parse(data);
98
- return store.jobs || [];
97
+ // jobs 배열 유효성 검사
98
+ if (!store || !Array.isArray(store.jobs)) {
99
+ console.warn("[Cron] Invalid store format, returning empty array");
100
+ return [];
101
+ }
102
+ return store.jobs;
99
103
  }
100
104
  catch (error) {
101
105
  if (error.code === "ENOENT") {
102
106
  return [];
103
107
  }
108
+ // JSON 파싱 오류
109
+ if (error instanceof SyntaxError) {
110
+ console.error("[Cron] Corrupted cron-jobs.json file:", error.message);
111
+ // 백업 파일 생성 시도
112
+ try {
113
+ const backupPath = `${getCronFilePath()}.corrupted.${Date.now()}`;
114
+ const data = await fs.readFile(getCronFilePath(), "utf-8").catch(() => "");
115
+ if (data) {
116
+ await fs.writeFile(backupPath, data, "utf-8");
117
+ console.log(`[Cron] Corrupted file backed up to: ${backupPath}`);
118
+ }
119
+ }
120
+ catch {
121
+ // 백업 실패는 무시
122
+ }
123
+ return [];
124
+ }
104
125
  console.error("[Cron] Failed to load jobs:", error);
105
126
  return [];
106
127
  }
@@ -446,12 +467,21 @@ function getDaysInMonth(year, month) {
446
467
  * Parse cron field like "1,3,5" or "1-5" or step values into array of numbers
447
468
  */
448
469
  function parseCronField(field, min, max) {
449
- if (field === "*") {
470
+ // 문자열/null/undefined 처리
471
+ if (!field || field.trim() === "") {
472
+ return Array.from({ length: max - min + 1 }, (_, i) => i + min); // wildcard로 처리
473
+ }
474
+ const trimmedField = field.trim();
475
+ if (trimmedField === "*") {
450
476
  return Array.from({ length: max - min + 1 }, (_, i) => i + min);
451
477
  }
452
478
  // Handle step values like */5
453
- if (field.startsWith("*/")) {
454
- const step = parseInt(field.slice(2), 10);
479
+ if (trimmedField.startsWith("*/")) {
480
+ const step = parseInt(trimmedField.slice(2), 10);
481
+ if (isNaN(step) || step <= 0) {
482
+ console.warn(`[Cron] Invalid step value in field: ${field}, using default`);
483
+ return Array.from({ length: max - min + 1 }, (_, i) => i + min);
484
+ }
455
485
  const values = [];
456
486
  for (let i = min; i <= max; i += step) {
457
487
  values.push(i);
@@ -459,39 +489,66 @@ function parseCronField(field, min, max) {
459
489
  return values;
460
490
  }
461
491
  const values = [];
462
- const parts = field.split(",");
492
+ const parts = trimmedField.split(",");
463
493
  for (const part of parts) {
464
- if (part.includes("-")) {
465
- const [start, end] = part.split("-").map(Number);
466
- for (let i = start; i <= end; i++) {
467
- values.push(i);
494
+ const trimmedPart = part.trim();
495
+ if (!trimmedPart)
496
+ continue;
497
+ if (trimmedPart.includes("-") && !trimmedPart.includes("/")) {
498
+ const [startStr, endStr] = trimmedPart.split("-");
499
+ const start = Number(startStr);
500
+ const end = Number(endStr);
501
+ if (!isNaN(start) && !isNaN(end)) {
502
+ for (let i = start; i <= end; i++) {
503
+ values.push(i);
504
+ }
468
505
  }
469
506
  }
470
- else if (part.includes("/")) {
507
+ else if (trimmedPart.includes("/")) {
471
508
  // Handle range with step like 0-30/5
472
- const [range, stepStr] = part.split("/");
509
+ const [range, stepStr] = trimmedPart.split("/");
473
510
  const step = parseInt(stepStr, 10);
511
+ if (isNaN(step) || step <= 0)
512
+ continue;
474
513
  let rangeStart = min;
475
514
  let rangeEnd = max;
476
- if (range.includes("-")) {
477
- [rangeStart, rangeEnd] = range.split("-").map(Number);
515
+ if (range && range.includes("-")) {
516
+ const [rs, re] = range.split("-").map(Number);
517
+ if (!isNaN(rs))
518
+ rangeStart = rs;
519
+ if (!isNaN(re))
520
+ rangeEnd = re;
478
521
  }
479
522
  for (let i = rangeStart; i <= rangeEnd; i += step) {
480
523
  values.push(i);
481
524
  }
482
525
  }
483
526
  else {
484
- values.push(parseInt(part, 10));
527
+ const num = parseInt(trimmedPart, 10);
528
+ if (!isNaN(num)) {
529
+ values.push(num);
530
+ }
485
531
  }
486
532
  }
487
- return values.filter((v) => v >= min && v <= max);
533
+ // 유효한 값이 없으면 wildcard로 폴백
534
+ const filtered = values.filter((v) => v >= min && v <= max);
535
+ return filtered.length > 0 ? filtered : Array.from({ length: max - min + 1 }, (_, i) => i + min);
488
536
  }
489
537
  /**
490
538
  * Get all jobs for a specific chat
491
539
  */
492
540
  export async function getJobsByChat(chatId) {
541
+ // null/undefined 처리
542
+ if (chatId == null) {
543
+ return [];
544
+ }
493
545
  const jobs = await loadJobs();
494
546
  const numericChatId = typeof chatId === "string" ? parseInt(chatId, 10) : chatId;
547
+ // NaN 체크
548
+ if (isNaN(numericChatId)) {
549
+ console.warn(`[Cron] Invalid chatId: ${chatId}`);
550
+ return [];
551
+ }
495
552
  return jobs.filter((j) => j.chatId === numericChatId);
496
553
  }
497
554
  /**
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 봇 헬스 체크 모듈
3
+ * 봇의 상태를 추적하고 모니터링합니다.
4
+ * @module health
5
+ */
6
+ let startTime = Date.now();
7
+ let lastActivity = Date.now();
8
+ let messageCount = 0;
9
+ let errorCount = 0;
10
+ /**
11
+ * 활동을 기록합니다.
12
+ * 메시지 처리 시 호출하여 마지막 활동 시간과 메시지 카운트를 업데이트합니다.
13
+ */
14
+ export function recordActivity() {
15
+ lastActivity = Date.now();
16
+ messageCount++;
17
+ }
18
+ /**
19
+ * 오류 발생을 기록합니다.
20
+ */
21
+ export function recordError() {
22
+ errorCount++;
23
+ }
24
+ /**
25
+ * 현재 봇의 건강 상태를 조회합니다.
26
+ * @returns 건강 상태 정보
27
+ */
28
+ export function getHealthStatus() {
29
+ const now = Date.now();
30
+ const uptime = Math.floor((now - startTime) / 1000);
31
+ const inactiveTime = now - lastActivity;
32
+ // 30분 이상 활동 없으면 unhealthy
33
+ const isHealthy = inactiveTime < 30 * 60 * 1000;
34
+ return {
35
+ uptime,
36
+ lastActivity,
37
+ messageCount,
38
+ errorCount,
39
+ isHealthy
40
+ };
41
+ }
42
+ /**
43
+ * 가동 시간을 읽기 좋은 형태로 포맷합니다.
44
+ * @param seconds - 가동 시간 (초)
45
+ * @returns 포맷된 문자열 (예: "2일 3시간", "5시간 30분")
46
+ */
47
+ export function formatUptime(seconds) {
48
+ const days = Math.floor(seconds / 86400);
49
+ const hours = Math.floor((seconds % 86400) / 3600);
50
+ const mins = Math.floor((seconds % 3600) / 60);
51
+ if (days > 0)
52
+ return `${days}일 ${hours}시간`;
53
+ if (hours > 0)
54
+ return `${hours}시간 ${mins}분`;
55
+ return `${mins}분`;
56
+ }
57
+ /**
58
+ * 헬스 상태를 초기화합니다.
59
+ * 테스트나 재시작 시 사용합니다.
60
+ */
61
+ export function resetHealth() {
62
+ startTime = Date.now();
63
+ lastActivity = Date.now();
64
+ messageCount = 0;
65
+ errorCount = 0;
66
+ }
@@ -4,16 +4,21 @@ 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";
7
+ import { checkForUpdates } from "../updates/index.js";
8
+ import { INTERVAL_30_MINUTES, INTERVAL_24_HOURS, hoursToMs } from "../utils/time.js";
8
9
  // 활성 타이머
9
10
  const activeTimers = new Map();
10
11
  // 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
11
12
  // lastCheckAt, lastMessageAt은 디버깅 용도라 매번 저장할 필요 없음
12
13
  const timestampCache = new Map();
14
+ // 업데이트 체크 캐시 (하루에 한 번만)
15
+ let lastUpdateCheck = 0;
16
+ let cachedUpdateInfo = null;
17
+ const UPDATE_CHECK_INTERVAL = INTERVAL_24_HOURS;
13
18
  // 봇 인스턴스
14
19
  let botInstance = null;
15
20
  // 기본 간격: 30분
16
- const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
21
+ const DEFAULT_INTERVAL_MS = INTERVAL_30_MINUTES;
17
22
  export function setHeartbeatBot(bot) {
18
23
  botInstance = bot;
19
24
  }
@@ -80,6 +85,21 @@ async function gatherContext() {
80
85
  // 무시
81
86
  }
82
87
  }
88
+ // 업데이트 체크 (하루에 한 번)
89
+ const timeSinceLastCheck = Date.now() - lastUpdateCheck;
90
+ if (timeSinceLastCheck > UPDATE_CHECK_INTERVAL) {
91
+ try {
92
+ cachedUpdateInfo = await checkForUpdates();
93
+ lastUpdateCheck = Date.now();
94
+ console.log(`[Heartbeat] Update check: current=${cachedUpdateInfo.current}, latest=${cachedUpdateInfo.latest}`);
95
+ }
96
+ catch (error) {
97
+ console.error("[Heartbeat] Update check failed:", error);
98
+ }
99
+ }
100
+ if (cachedUpdateInfo?.hasUpdate) {
101
+ parts.push(`🆕 업데이트 알림: CompanionBot ${cachedUpdateInfo.latest} 버전이 출시됨! (현재: ${cachedUpdateInfo.current})`);
102
+ }
83
103
  return parts.join("\n");
84
104
  }
85
105
  // Heartbeat 실행 - 메시지를 보냈으면 true 반환
@@ -121,10 +141,7 @@ ${context}
121
141
  ];
122
142
  let messageSent = false;
123
143
  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
- });
144
+ const response = await chat(messages, systemPrompt, "haiku");
128
145
  if (!response.trim().includes("HEARTBEAT_OK")) {
129
146
  await botInstance.api.sendMessage(config.chatId, response);
130
147
  console.log(`[Heartbeat] Sent message to ${config.chatId}`);
@@ -261,7 +278,7 @@ export async function runHeartbeatNow(chatId) {
261
278
  enabled: false,
262
279
  intervalMs: DEFAULT_INTERVAL_MS,
263
280
  lastCheckAt: Date.now(),
264
- lastMessageAt: Date.now() - (8 * 60 * 60 * 1000), // 8시간 전으로 설정
281
+ lastMessageAt: Date.now() - hoursToMs(8), // 8시간 전으로 설정
265
282
  };
266
283
  return await executeHeartbeat(defaultConfig);
267
284
  }
@@ -0,0 +1,113 @@
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
+ // null/undefined 처리
41
+ if (text == null) {
42
+ return new Array(384).fill(0);
43
+ }
44
+ const pipe = await getEmbeddingPipeline();
45
+ // 텍스트 정규화
46
+ const cleanText = text.trim().slice(0, 512); // 최대 512자
47
+ if (!cleanText) {
48
+ return new Array(384).fill(0);
49
+ }
50
+ const result = await pipe(cleanText, {
51
+ pooling: "mean",
52
+ normalize: true,
53
+ });
54
+ // Tensor를 배열로 변환
55
+ return Array.from(result.data);
56
+ }
57
+ /**
58
+ * 여러 텍스트를 배치로 임베딩합니다.
59
+ * 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
60
+ * @param texts 변환할 텍스트 배열
61
+ * @returns 임베딩 벡터 배열
62
+ */
63
+ export async function embedBatch(texts) {
64
+ // null/undefined 배열 처리
65
+ if (!texts || texts.length === 0)
66
+ return [];
67
+ if (texts.length === 1)
68
+ return [await embed(texts[0])];
69
+ // 동시성 제한 (메모리 보호)
70
+ const CONCURRENCY = 5;
71
+ const results = new Array(texts.length);
72
+ for (let i = 0; i < texts.length; i += CONCURRENCY) {
73
+ const batch = texts.slice(i, i + CONCURRENCY);
74
+ const batchResults = await Promise.all(batch.map(text => embed(text)));
75
+ for (let j = 0; j < batchResults.length; j++) {
76
+ results[i + j] = batchResults[j];
77
+ }
78
+ }
79
+ return results;
80
+ }
81
+ /**
82
+ * 두 벡터 간의 코사인 유사도를 계산합니다.
83
+ *
84
+ * 최적화: embed()에서 normalize: true로 정규화된 벡터를 반환하므로,
85
+ * 정규화된 벡터의 경우 코사인 유사도 = 내적 (norm이 1이므로)
86
+ * normalized 파라미터가 true면 내적만 계산하여 성능 향상.
87
+ */
88
+ export function cosineSimilarity(a, b, normalized = true) {
89
+ // null/undefined 또는 빈 배열 처리
90
+ if (!a || !b || a.length === 0 || b.length === 0)
91
+ return 0;
92
+ if (a.length !== b.length)
93
+ return 0;
94
+ let dotProduct = 0;
95
+ for (let i = 0; i < a.length; i++) {
96
+ dotProduct += a[i] * b[i];
97
+ }
98
+ // 정규화된 벡터면 내적 = 코사인 유사도
99
+ if (normalized) {
100
+ return dotProduct;
101
+ }
102
+ // 정규화되지 않은 벡터면 norm 계산 필요
103
+ let normA = 0;
104
+ let normB = 0;
105
+ for (let i = 0; i < a.length; i++) {
106
+ normA += a[i] * a[i];
107
+ normB += b[i] * b[i];
108
+ }
109
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
110
+ if (denominator === 0)
111
+ return 0;
112
+ return dotProduct / denominator;
113
+ }
@@ -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
+ }