companionbot 0.8.2 → 0.10.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,6 +1,7 @@
1
1
  /**
2
2
  * 간단한 벡터 저장소 모듈
3
3
  * 메모리 파일들을 로드하고 유사도 기반으로 검색합니다.
4
+ * 임베딩은 파일에 캐시되어 재시작 후에도 유지됩니다.
4
5
  */
5
6
  import * as fs from "fs/promises";
6
7
  import * as path from "path";
@@ -10,8 +11,61 @@ import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
10
11
  let cachedChunks = [];
11
12
  let cacheTimestamp = 0;
12
13
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
14
+ // 임베딩 영속 캐시 (hash → embedding)
15
+ let embeddingCache = new Map();
16
+ let embeddingCacheLoaded = false;
13
17
  // 로딩 중복 방지용 Promise
14
18
  let loadingPromise = null;
19
+ /**
20
+ * 간단한 해시 함수 (텍스트 변경 감지용)
21
+ */
22
+ function simpleHash(text) {
23
+ let hash = 0;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const char = text.charCodeAt(i);
26
+ hash = ((hash << 5) - hash) + char;
27
+ hash = hash & hash; // 32bit 정수로 변환
28
+ }
29
+ return hash.toString(16);
30
+ }
31
+ /**
32
+ * 임베딩 캐시 파일 경로
33
+ */
34
+ function getEmbeddingCachePath() {
35
+ return path.join(getMemoryDirPath(), ".embedding-cache.json");
36
+ }
37
+ /**
38
+ * 임베딩 캐시를 파일에서 로드합니다.
39
+ */
40
+ async function loadEmbeddingCache() {
41
+ if (embeddingCacheLoaded)
42
+ return;
43
+ try {
44
+ const cachePath = getEmbeddingCachePath();
45
+ const data = await fs.readFile(cachePath, "utf-8");
46
+ const parsed = JSON.parse(data);
47
+ embeddingCache = new Map(Object.entries(parsed));
48
+ console.log(`[VectorStore] Loaded ${embeddingCache.size} cached embeddings`);
49
+ }
50
+ catch {
51
+ // 파일 없거나 파싱 실패 - 새로 시작
52
+ embeddingCache = new Map();
53
+ }
54
+ embeddingCacheLoaded = true;
55
+ }
56
+ /**
57
+ * 임베딩 캐시를 파일에 저장합니다.
58
+ */
59
+ async function saveEmbeddingCache() {
60
+ try {
61
+ const cachePath = getEmbeddingCachePath();
62
+ const obj = Object.fromEntries(embeddingCache);
63
+ await fs.writeFile(cachePath, JSON.stringify(obj), "utf-8");
64
+ }
65
+ catch (error) {
66
+ console.warn("[VectorStore] Failed to save embedding cache:", error);
67
+ }
68
+ }
15
69
  /**
16
70
  * 텍스트를 적절한 크기의 청크로 분할합니다.
17
71
  */
@@ -30,7 +84,11 @@ function splitIntoChunks(text, source) {
30
84
  for (const line of lines) {
31
85
  if (currentChunk.length + line.length > 500) {
32
86
  if (currentChunk.trim()) {
33
- chunks.push({ text: currentChunk.trim(), source });
87
+ chunks.push({
88
+ text: currentChunk.trim(),
89
+ source,
90
+ hash: simpleHash(currentChunk.trim())
91
+ });
34
92
  }
35
93
  currentChunk = line;
36
94
  }
@@ -39,11 +97,19 @@ function splitIntoChunks(text, source) {
39
97
  }
40
98
  }
41
99
  if (currentChunk.trim()) {
42
- chunks.push({ text: currentChunk.trim(), source });
100
+ chunks.push({
101
+ text: currentChunk.trim(),
102
+ source,
103
+ hash: simpleHash(currentChunk.trim())
104
+ });
43
105
  }
44
106
  }
45
107
  else {
46
- chunks.push({ text: trimmed, source });
108
+ chunks.push({
109
+ text: trimmed,
110
+ source,
111
+ hash: simpleHash(trimmed)
112
+ });
47
113
  }
48
114
  }
49
115
  return chunks;
@@ -52,12 +118,14 @@ function splitIntoChunks(text, source) {
52
118
  * 내부 로드 로직 - 실제 파일 로드 수행
53
119
  */
54
120
  async function doLoadAllMemoryChunks() {
121
+ // 임베딩 캐시 로드
122
+ await loadEmbeddingCache();
55
123
  const chunks = [];
56
124
  // 1. 일별 메모리 파일 (최근 30일)
57
125
  const memoryDir = getMemoryDirPath();
58
126
  try {
59
127
  const files = await fs.readdir(memoryDir);
60
- const mdFiles = files.filter(f => f.endsWith(".md")).sort().reverse().slice(0, 30);
128
+ const mdFiles = files.filter(f => f.endsWith(".md") && !f.startsWith(".")).sort().reverse().slice(0, 30);
61
129
  for (const file of mdFiles) {
62
130
  try {
63
131
  const content = await fs.readFile(path.join(memoryDir, file), "utf-8");
@@ -82,6 +150,15 @@ async function doLoadAllMemoryChunks() {
82
150
  catch {
83
151
  // 파일 없음 무시
84
152
  }
153
+ // 3. 캐시된 임베딩 복원
154
+ for (const chunk of chunks) {
155
+ if (chunk.hash) {
156
+ const cachedEmbedding = embeddingCache.get(chunk.hash);
157
+ if (cachedEmbedding) {
158
+ chunk.embedding = cachedEmbedding;
159
+ }
160
+ }
161
+ }
85
162
  return chunks;
86
163
  }
87
164
  /**
@@ -131,24 +208,35 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
131
208
  // 임베딩이 없는 청크들을 배치로 처리
132
209
  const chunksNeedingEmbedding = chunks.filter(c => !c.embedding);
133
210
  if (chunksNeedingEmbedding.length > 0) {
211
+ console.log(`[VectorStore] Generating embeddings for ${chunksNeedingEmbedding.length} chunks`);
134
212
  try {
135
213
  const texts = chunksNeedingEmbedding.map(c => c.text);
136
214
  const embeddings = await embedBatch(texts);
137
- // 임베딩 할당
215
+ // 임베딩 할당 및 캐시 저장
138
216
  for (let i = 0; i < chunksNeedingEmbedding.length; i++) {
139
- chunksNeedingEmbedding[i].embedding = embeddings[i];
217
+ const chunk = chunksNeedingEmbedding[i];
218
+ chunk.embedding = embeddings[i];
219
+ if (chunk.hash) {
220
+ embeddingCache.set(chunk.hash, embeddings[i]);
221
+ }
140
222
  }
223
+ // 캐시 파일 저장 (비동기, 실패해도 무시)
224
+ saveEmbeddingCache().catch(() => { });
141
225
  }
142
226
  catch {
143
227
  // 배치 실패 시 개별 처리 폴백
144
228
  for (const chunk of chunksNeedingEmbedding) {
145
229
  try {
146
230
  chunk.embedding = await embed(chunk.text);
231
+ if (chunk.hash) {
232
+ embeddingCache.set(chunk.hash, chunk.embedding);
233
+ }
147
234
  }
148
235
  catch {
149
236
  // 개별 실패 무시
150
237
  }
151
238
  }
239
+ saveEmbeddingCache().catch(() => { });
152
240
  }
153
241
  }
154
242
  // 유사도 계산 및 필터링
@@ -172,12 +260,27 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
172
260
  }
173
261
  /**
174
262
  * 캐시를 무효화합니다.
263
+ * 임베딩 캐시는 유지 (텍스트 해시 기반이므로)
175
264
  */
176
265
  export function invalidateCache() {
177
266
  cachedChunks = [];
178
267
  cacheTimestamp = 0;
179
268
  loadingPromise = null;
180
269
  }
270
+ /**
271
+ * 임베딩 캐시까지 완전 초기화합니다.
272
+ */
273
+ export async function clearAllCaches() {
274
+ invalidateCache();
275
+ embeddingCache.clear();
276
+ embeddingCacheLoaded = false;
277
+ try {
278
+ await fs.unlink(getEmbeddingCachePath());
279
+ }
280
+ catch {
281
+ // 파일 없으면 무시
282
+ }
283
+ }
181
284
  // 인메모리 저장소 (간단한 구현)
182
285
  let vectorStore = [];
183
286
  /**
@@ -0,0 +1,194 @@
1
+ /**
2
+ * JSONL 기반 세션 영구 저장
3
+ *
4
+ * OpenClaw 스타일로 대화 기록을 JSONL 파일로 저장
5
+ * - 저장 경로: ~/.companionbot/sessions/{chatId}.jsonl
6
+ * - 메시지마다 한 줄씩 append
7
+ */
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as os from "os";
11
+ import * as readline from "readline";
12
+ // 저장 경로
13
+ const SESSIONS_DIR = path.join(os.homedir(), ".companionbot", "sessions");
14
+ /**
15
+ * 세션 디렉토리 초기화 (없으면 생성)
16
+ */
17
+ function ensureSessionsDir() {
18
+ if (!fs.existsSync(SESSIONS_DIR)) {
19
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
20
+ console.log(`[Persistence] Created sessions directory: ${SESSIONS_DIR}`);
21
+ }
22
+ }
23
+ /**
24
+ * chatId에 해당하는 JSONL 파일 경로
25
+ */
26
+ function getSessionFilePath(chatId) {
27
+ return path.join(SESSIONS_DIR, `${chatId}.jsonl`);
28
+ }
29
+ /**
30
+ * 메시지를 JSONL 파일에 append
31
+ */
32
+ export function appendMessage(chatId, role, content) {
33
+ ensureSessionsDir();
34
+ const filePath = getSessionFilePath(chatId);
35
+ const message = {
36
+ role,
37
+ content,
38
+ timestamp: Date.now(),
39
+ };
40
+ const line = JSON.stringify(message) + "\n";
41
+ try {
42
+ fs.appendFileSync(filePath, line, "utf-8");
43
+ }
44
+ catch (error) {
45
+ console.error(`[Persistence] Failed to append message to ${filePath}:`, error);
46
+ }
47
+ }
48
+ /**
49
+ * JSONL 파일에서 히스토리 로드
50
+ *
51
+ * @param chatId 채팅 ID
52
+ * @param limit 최근 N개만 로드 (메모리 절약, 0 = 전부)
53
+ * @returns 로드된 메시지 배열
54
+ */
55
+ export async function loadHistory(chatId, limit = 100) {
56
+ const filePath = getSessionFilePath(chatId);
57
+ if (!fs.existsSync(filePath)) {
58
+ return [];
59
+ }
60
+ const messages = [];
61
+ try {
62
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
63
+ const rl = readline.createInterface({
64
+ input: fileStream,
65
+ crlfDelay: Infinity,
66
+ });
67
+ for await (const line of rl) {
68
+ if (line.trim()) {
69
+ try {
70
+ const msg = JSON.parse(line);
71
+ messages.push(msg);
72
+ }
73
+ catch (parseError) {
74
+ console.warn(`[Persistence] Skipping malformed line in ${filePath}`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ catch (error) {
80
+ console.error(`[Persistence] Failed to load history from ${filePath}:`, error);
81
+ return [];
82
+ }
83
+ // limit이 0이면 전부, 아니면 최근 N개만
84
+ if (limit > 0 && messages.length > limit) {
85
+ return messages.slice(-limit);
86
+ }
87
+ return messages;
88
+ }
89
+ /**
90
+ * 동기 버전 히스토리 로드 (초기화 시 사용)
91
+ */
92
+ export function loadHistorySync(chatId, limit = 100) {
93
+ const filePath = getSessionFilePath(chatId);
94
+ if (!fs.existsSync(filePath)) {
95
+ return [];
96
+ }
97
+ const messages = [];
98
+ try {
99
+ const content = fs.readFileSync(filePath, "utf-8");
100
+ const lines = content.split("\n");
101
+ for (const line of lines) {
102
+ if (line.trim()) {
103
+ try {
104
+ const msg = JSON.parse(line);
105
+ messages.push(msg);
106
+ }
107
+ catch (parseError) {
108
+ console.warn(`[Persistence] Skipping malformed line in ${filePath}`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ catch (error) {
114
+ console.error(`[Persistence] Failed to load history from ${filePath}:`, error);
115
+ return [];
116
+ }
117
+ // limit이 0이면 전부, 아니면 최근 N개만
118
+ if (limit > 0 && messages.length > limit) {
119
+ return messages.slice(-limit);
120
+ }
121
+ return messages;
122
+ }
123
+ /**
124
+ * 전체 히스토리 개수 (파일에서)
125
+ */
126
+ export function getHistoryCount(chatId) {
127
+ const filePath = getSessionFilePath(chatId);
128
+ if (!fs.existsSync(filePath)) {
129
+ return 0;
130
+ }
131
+ try {
132
+ const content = fs.readFileSync(filePath, "utf-8");
133
+ return content.split("\n").filter(line => line.trim()).length;
134
+ }
135
+ catch {
136
+ return 0;
137
+ }
138
+ }
139
+ /**
140
+ * 세션 파일 삭제 (히스토리 완전 삭제)
141
+ */
142
+ export function deleteSessionFile(chatId) {
143
+ const filePath = getSessionFilePath(chatId);
144
+ if (!fs.existsSync(filePath)) {
145
+ return false;
146
+ }
147
+ try {
148
+ fs.unlinkSync(filePath);
149
+ console.log(`[Persistence] Deleted session file: ${filePath}`);
150
+ return true;
151
+ }
152
+ catch (error) {
153
+ console.error(`[Persistence] Failed to delete session file:`, error);
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * 세션 파일 존재 여부
159
+ */
160
+ export function sessionFileExists(chatId) {
161
+ return fs.existsSync(getSessionFilePath(chatId));
162
+ }
163
+ /**
164
+ * 모든 세션 파일 목록
165
+ */
166
+ export function listSessionFiles() {
167
+ ensureSessionsDir();
168
+ try {
169
+ const files = fs.readdirSync(SESSIONS_DIR);
170
+ return files
171
+ .filter(f => f.endsWith(".jsonl"))
172
+ .map(f => parseInt(f.replace(".jsonl", ""), 10))
173
+ .filter(id => !isNaN(id));
174
+ }
175
+ catch {
176
+ return [];
177
+ }
178
+ }
179
+ /**
180
+ * 히스토리 검색 (파일 전체에서)
181
+ *
182
+ * @param chatId 채팅 ID
183
+ * @param query 검색어
184
+ * @param limit 최대 결과 수
185
+ * @returns 매칭된 메시지들
186
+ */
187
+ export async function searchHistory(chatId, query, limit = 10) {
188
+ const all = await loadHistory(chatId, 0); // 전부 로드
189
+ const lowerQuery = query.toLowerCase();
190
+ const matches = all
191
+ .filter(msg => msg.content.toLowerCase().includes(lowerQuery))
192
+ .slice(-limit); // 최근 것부터
193
+ return matches;
194
+ }