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.
- package/dist/ai/claude.js +21 -10
- package/dist/cron/scheduler.js +2 -2
- package/dist/heartbeat/index.js +3 -3
- package/dist/memory/ftsIndex.js +148 -0
- package/dist/memory/hybridSearch.js +103 -0
- package/dist/memory/index.js +10 -3
- package/dist/memory/indexer.js +148 -21
- package/dist/memory/vectorStore.js +109 -6
- package/dist/session/persistence.js +194 -0
- package/dist/session/state.js +275 -21
- package/dist/telegram/handlers/commands.js +96 -7
- package/dist/telegram/handlers/messages.js +75 -25
- package/dist/telegram/utils/index.js +1 -1
- package/dist/telegram/utils/prompt.js +221 -65
- package/dist/telegram/utils/url.js +34 -1
- package/dist/tools/index.js +21 -3
- package/dist/workspace/load.js +112 -8
- package/package.json +3 -1
- package/templates/AGENTS.md +49 -14
|
@@ -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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
+
}
|