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
package/dist/ai/claude.js
CHANGED
|
@@ -75,6 +75,7 @@ export const MODELS = {
|
|
|
75
75
|
export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
76
76
|
const client = getClient();
|
|
77
77
|
const modelConfig = MODELS[modelId];
|
|
78
|
+
const toolsUsed = [];
|
|
78
79
|
// 메시지를 API 형식으로 변환
|
|
79
80
|
const apiMessages = messages.map((m) => ({
|
|
80
81
|
role: m.role,
|
|
@@ -122,6 +123,12 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
122
123
|
tool_use_id: toolUse.id,
|
|
123
124
|
content: truncatedResult,
|
|
124
125
|
});
|
|
126
|
+
// 도구 사용 기록 (히스토리 참조용)
|
|
127
|
+
toolsUsed.push({
|
|
128
|
+
name: toolUse.name,
|
|
129
|
+
input: JSON.stringify(toolUse.input).slice(0, 200),
|
|
130
|
+
output: truncatedResult.slice(0, 500),
|
|
131
|
+
});
|
|
125
132
|
}
|
|
126
133
|
// 어시스턴트 메시지와 도구 결과 추가
|
|
127
134
|
apiMessages.push({
|
|
@@ -138,11 +145,14 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
138
145
|
// 반복 횟수 초과 시 경고
|
|
139
146
|
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
140
147
|
console.warn(`[Warning] Tool use loop reached max iterations (${MAX_TOOL_ITERATIONS})`);
|
|
141
|
-
return "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?";
|
|
148
|
+
return { text: "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?", toolsUsed };
|
|
142
149
|
}
|
|
143
150
|
// 최종 텍스트 응답 추출
|
|
144
151
|
const textBlock = response.content.find((block) => block.type === "text");
|
|
145
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
text: textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?",
|
|
154
|
+
toolsUsed
|
|
155
|
+
};
|
|
146
156
|
}
|
|
147
157
|
/**
|
|
148
158
|
* 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
|
|
@@ -158,8 +168,8 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
158
168
|
export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
159
169
|
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
160
170
|
if (!onChunk) {
|
|
161
|
-
const
|
|
162
|
-
return { text, usedTools:
|
|
171
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
172
|
+
return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
|
|
163
173
|
}
|
|
164
174
|
const client = getClient();
|
|
165
175
|
const modelConfig = MODELS[modelId];
|
|
@@ -204,11 +214,11 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
204
214
|
// 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
|
|
205
215
|
if (stopReason === "tool_use") {
|
|
206
216
|
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
207
|
-
const
|
|
208
|
-
return { text, usedTools: true };
|
|
217
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
218
|
+
return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
|
|
209
219
|
}
|
|
210
220
|
// 성공적으로 스트리밍 완료
|
|
211
|
-
return { text: accumulated, usedTools: false };
|
|
221
|
+
return { text: accumulated, usedTools: false, toolsUsed: [] };
|
|
212
222
|
}
|
|
213
223
|
catch (error) {
|
|
214
224
|
// 스트리밍 시작 전 에러 (연결 실패 등) - 재시도 가능
|
|
@@ -218,8 +228,8 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
218
228
|
console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
|
|
219
229
|
return await withRetry(async () => {
|
|
220
230
|
// 재시도 시 일반 chat 사용 (스트리밍 대신)
|
|
221
|
-
const
|
|
222
|
-
return { text, usedTools: false };
|
|
231
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
232
|
+
return { text: result.text, usedTools: false, toolsUsed: result.toolsUsed };
|
|
223
233
|
});
|
|
224
234
|
}
|
|
225
235
|
}
|
|
@@ -230,7 +240,8 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
230
240
|
if (accumulated.length > 0) {
|
|
231
241
|
return {
|
|
232
242
|
text: accumulated + "\n\n(응답 생성 중 오류 발생)",
|
|
233
|
-
usedTools: false
|
|
243
|
+
usedTools: false,
|
|
244
|
+
toolsUsed: []
|
|
234
245
|
};
|
|
235
246
|
}
|
|
236
247
|
}
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -167,9 +167,9 @@ 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
|
-
const
|
|
170
|
+
const result = await chat(messages, systemPrompt, "sonnet");
|
|
171
171
|
// Send the response to the chat
|
|
172
|
-
const trimmedResponse =
|
|
172
|
+
const trimmedResponse = result.text?.trim();
|
|
173
173
|
if (trimmedResponse) {
|
|
174
174
|
// Split long messages (Telegram limit is 4096 characters)
|
|
175
175
|
const maxLength = TELEGRAM_SAFE_LIMIT;
|
package/dist/heartbeat/index.js
CHANGED
|
@@ -141,9 +141,9 @@ ${context}
|
|
|
141
141
|
];
|
|
142
142
|
let messageSent = false;
|
|
143
143
|
try {
|
|
144
|
-
const
|
|
145
|
-
if (!
|
|
146
|
-
await botInstance.api.sendMessage(config.chatId,
|
|
144
|
+
const result = await chat(messages, systemPrompt, "haiku");
|
|
145
|
+
if (!result.text.trim().includes("HEARTBEAT_OK")) {
|
|
146
|
+
await botInstance.api.sendMessage(config.chatId, result.text);
|
|
147
147
|
console.log(`[Heartbeat] Sent message to ${config.chatId}`);
|
|
148
148
|
messageSent = true;
|
|
149
149
|
// 타임스탬프는 메모리 캐시에만 저장 (파일 쓰기 안 함)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTS5 기반 키워드 검색 인덱스 모듈
|
|
3
|
+
* SQLite FTS5를 사용하여 전문 검색을 제공합니다.
|
|
4
|
+
*/
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { getMemoryDirPath } from "../workspace/paths.js";
|
|
8
|
+
// 싱글톤 DB 인스턴스
|
|
9
|
+
let db = null;
|
|
10
|
+
/**
|
|
11
|
+
* FTS5 데이터베이스 경로
|
|
12
|
+
*/
|
|
13
|
+
function getDbPath() {
|
|
14
|
+
return path.join(getMemoryDirPath(), ".fts-index.db");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 데이터베이스 인스턴스를 가져오거나 생성합니다.
|
|
18
|
+
*/
|
|
19
|
+
function getDb() {
|
|
20
|
+
if (db)
|
|
21
|
+
return db;
|
|
22
|
+
const dbPath = getDbPath();
|
|
23
|
+
db = new Database(dbPath);
|
|
24
|
+
// FTS5 테이블 생성 (없으면)
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
27
|
+
id,
|
|
28
|
+
source,
|
|
29
|
+
text,
|
|
30
|
+
content='',
|
|
31
|
+
tokenize='unicode61'
|
|
32
|
+
);
|
|
33
|
+
`);
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 텍스트를 FTS 인덱스에 추가합니다.
|
|
38
|
+
* 기존 id가 있으면 업데이트합니다.
|
|
39
|
+
*/
|
|
40
|
+
export function indexText(id, source, text) {
|
|
41
|
+
const database = getDb();
|
|
42
|
+
// 기존 엔트리 삭제 후 삽입 (upsert)
|
|
43
|
+
const deleteStmt = database.prepare("DELETE FROM memory_fts WHERE id = ?");
|
|
44
|
+
const insertStmt = database.prepare("INSERT INTO memory_fts (id, source, text) VALUES (?, ?, ?)");
|
|
45
|
+
const transaction = database.transaction(() => {
|
|
46
|
+
deleteStmt.run(id);
|
|
47
|
+
insertStmt.run(id, source, text);
|
|
48
|
+
});
|
|
49
|
+
transaction();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 여러 텍스트를 배치로 인덱싱합니다.
|
|
53
|
+
*/
|
|
54
|
+
export function indexTextBatch(entries) {
|
|
55
|
+
if (entries.length === 0)
|
|
56
|
+
return;
|
|
57
|
+
const database = getDb();
|
|
58
|
+
const deleteStmt = database.prepare("DELETE FROM memory_fts WHERE id = ?");
|
|
59
|
+
const insertStmt = database.prepare("INSERT INTO memory_fts (id, source, text) VALUES (?, ?, ?)");
|
|
60
|
+
const transaction = database.transaction(() => {
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
deleteStmt.run(entry.id);
|
|
63
|
+
insertStmt.run(entry.id, entry.source, entry.text);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
transaction();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 키워드로 검색합니다.
|
|
70
|
+
* FTS5 MATCH 쿼리와 BM25 랭킹 사용
|
|
71
|
+
*/
|
|
72
|
+
export function searchKeyword(query, limit = 10) {
|
|
73
|
+
const database = getDb();
|
|
74
|
+
// 쿼리 정규화 (특수문자 제거, 공백으로 분리)
|
|
75
|
+
const cleanQuery = query
|
|
76
|
+
.replace(/[^\w\s가-힣]/g, " ")
|
|
77
|
+
.trim()
|
|
78
|
+
.split(/\s+/)
|
|
79
|
+
.filter(word => word.length > 0)
|
|
80
|
+
.map(word => `"${word}"`) // 정확한 단어 매칭
|
|
81
|
+
.join(" OR "); // OR 검색
|
|
82
|
+
if (!cleanQuery)
|
|
83
|
+
return [];
|
|
84
|
+
try {
|
|
85
|
+
const stmt = database.prepare(`
|
|
86
|
+
SELECT id, source, text, bm25(memory_fts) as score
|
|
87
|
+
FROM memory_fts
|
|
88
|
+
WHERE memory_fts MATCH ?
|
|
89
|
+
ORDER BY score
|
|
90
|
+
LIMIT ?
|
|
91
|
+
`);
|
|
92
|
+
const results = stmt.all(cleanQuery, limit);
|
|
93
|
+
return results.map(r => ({
|
|
94
|
+
id: r.id,
|
|
95
|
+
source: r.source,
|
|
96
|
+
text: r.text,
|
|
97
|
+
score: r.score,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// 쿼리 파싱 실패 시 빈 결과
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 특정 소스의 모든 엔트리를 삭제합니다.
|
|
107
|
+
*/
|
|
108
|
+
export function deleteBySource(source) {
|
|
109
|
+
const database = getDb();
|
|
110
|
+
const stmt = database.prepare("DELETE FROM memory_fts WHERE source = ?");
|
|
111
|
+
const result = stmt.run(source);
|
|
112
|
+
return result.changes;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 특정 ID의 엔트리를 삭제합니다.
|
|
116
|
+
*/
|
|
117
|
+
export function deleteById(id) {
|
|
118
|
+
const database = getDb();
|
|
119
|
+
const stmt = database.prepare("DELETE FROM memory_fts WHERE id = ?");
|
|
120
|
+
const result = stmt.run(id);
|
|
121
|
+
return result.changes > 0;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 전체 인덱스를 초기화합니다.
|
|
125
|
+
*/
|
|
126
|
+
export function clearIndex() {
|
|
127
|
+
const database = getDb();
|
|
128
|
+
database.exec("DELETE FROM memory_fts");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 인덱스의 총 문서 수를 반환합니다.
|
|
132
|
+
*/
|
|
133
|
+
export function getDocumentCount() {
|
|
134
|
+
const database = getDb();
|
|
135
|
+
const stmt = database.prepare("SELECT COUNT(*) as count FROM memory_fts");
|
|
136
|
+
const result = stmt.get();
|
|
137
|
+
return result.count;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 데이터베이스 연결을 닫습니다.
|
|
141
|
+
* 애플리케이션 종료 시 호출
|
|
142
|
+
*/
|
|
143
|
+
export function closeDb() {
|
|
144
|
+
if (db) {
|
|
145
|
+
db.close();
|
|
146
|
+
db = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 하이브리드 검색 모듈
|
|
3
|
+
* 벡터 검색 + 키워드 검색을 결합하여 최적의 검색 결과를 제공합니다.
|
|
4
|
+
*/
|
|
5
|
+
import { embed } from "./embeddings.js";
|
|
6
|
+
import { search as vectorSearch } from "./vectorStore.js";
|
|
7
|
+
import { searchKeyword } from "./ftsIndex.js";
|
|
8
|
+
// 가중치 설정
|
|
9
|
+
const VECTOR_WEIGHT = 0.7;
|
|
10
|
+
const KEYWORD_WEIGHT = 0.3;
|
|
11
|
+
/**
|
|
12
|
+
* BM25 점수를 0-1 범위로 정규화합니다.
|
|
13
|
+
* BM25는 낮을수록 관련성이 높으므로 반전시킵니다.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeBm25Score(score, minScore, maxScore) {
|
|
16
|
+
if (maxScore === minScore)
|
|
17
|
+
return 1;
|
|
18
|
+
// BM25는 음수 (낮을수록 좋음) → 정규화 후 반전
|
|
19
|
+
const normalized = (maxScore - score) / (maxScore - minScore);
|
|
20
|
+
return Math.max(0, Math.min(1, normalized));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 벡터 + 키워드 하이브리드 검색을 수행합니다.
|
|
24
|
+
*
|
|
25
|
+
* @param query 검색 쿼리
|
|
26
|
+
* @param topK 반환할 최대 결과 수
|
|
27
|
+
* @param vectorWeight 벡터 검색 가중치 (기본 0.7)
|
|
28
|
+
* @param keywordWeight 키워드 검색 가중치 (기본 0.3)
|
|
29
|
+
*/
|
|
30
|
+
export async function hybridSearch(query, topK = 5, vectorWeight = VECTOR_WEIGHT, keywordWeight = KEYWORD_WEIGHT) {
|
|
31
|
+
// 병렬로 두 검색 수행
|
|
32
|
+
const [queryEmbedding, keywordResults] = await Promise.all([
|
|
33
|
+
embed(query),
|
|
34
|
+
Promise.resolve(searchKeyword(query, topK * 2)), // 키워드 검색은 더 많이 가져옴
|
|
35
|
+
]);
|
|
36
|
+
// 벡터 검색 수행
|
|
37
|
+
const vectorResults = await vectorSearch(queryEmbedding, topK * 2, 0.2);
|
|
38
|
+
// 결과가 모두 없으면 빈 배열 반환
|
|
39
|
+
if (vectorResults.length === 0 && keywordResults.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
// 점수 병합을 위한 Map (key: text의 hash)
|
|
43
|
+
const scoreMap = new Map();
|
|
44
|
+
// 벡터 결과 처리 (코사인 유사도: 이미 0-1 범위)
|
|
45
|
+
for (const result of vectorResults) {
|
|
46
|
+
const key = makeKey(result.text, result.source);
|
|
47
|
+
scoreMap.set(key, {
|
|
48
|
+
text: result.text,
|
|
49
|
+
source: result.source,
|
|
50
|
+
score: result.score * vectorWeight,
|
|
51
|
+
vectorScore: result.score,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// 키워드 결과 정규화 및 병합
|
|
55
|
+
if (keywordResults.length > 0) {
|
|
56
|
+
const minBm25 = Math.min(...keywordResults.map(r => r.score));
|
|
57
|
+
const maxBm25 = Math.max(...keywordResults.map(r => r.score));
|
|
58
|
+
for (const result of keywordResults) {
|
|
59
|
+
const key = makeKey(result.text, result.source);
|
|
60
|
+
const normalizedScore = normalizeBm25Score(result.score, minBm25, maxBm25);
|
|
61
|
+
const existing = scoreMap.get(key);
|
|
62
|
+
if (existing) {
|
|
63
|
+
// 이미 벡터 결과에 있으면 점수 합산
|
|
64
|
+
existing.score += normalizedScore * keywordWeight;
|
|
65
|
+
existing.keywordScore = normalizedScore;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// 새로운 결과
|
|
69
|
+
scoreMap.set(key, {
|
|
70
|
+
text: result.text,
|
|
71
|
+
source: result.source,
|
|
72
|
+
score: normalizedScore * keywordWeight,
|
|
73
|
+
keywordScore: normalizedScore,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 점수 기준 정렬 후 상위 K개 반환
|
|
79
|
+
const results = Array.from(scoreMap.values())
|
|
80
|
+
.sort((a, b) => b.score - a.score)
|
|
81
|
+
.slice(0, topK);
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 벡터 검색만 수행합니다. (기존 동작 호환)
|
|
86
|
+
*/
|
|
87
|
+
export async function searchVector(query, topK = 5, minScore = 0.3) {
|
|
88
|
+
const queryEmbedding = await embed(query);
|
|
89
|
+
return vectorSearch(queryEmbedding, topK, minScore);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 키워드 검색만 수행합니다.
|
|
93
|
+
*/
|
|
94
|
+
export function searchByKeyword(query, limit = 10) {
|
|
95
|
+
return searchKeyword(query, limit);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 텍스트와 소스로 고유 키를 생성합니다.
|
|
99
|
+
*/
|
|
100
|
+
function makeKey(text, source) {
|
|
101
|
+
// 간단한 해시: 처음 100자 + 소스
|
|
102
|
+
return `${source}:${text.slice(0, 100)}`;
|
|
103
|
+
}
|
package/dist/memory/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
// Memory module exports
|
|
2
|
-
|
|
3
|
-
export {
|
|
4
|
-
|
|
2
|
+
// Embeddings
|
|
3
|
+
export { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
|
|
4
|
+
// Vector store
|
|
5
|
+
export { search, invalidateCache, loadAllMemoryChunks } from "./vectorStore.js";
|
|
6
|
+
// FTS index
|
|
7
|
+
export { indexText, indexTextBatch, searchKeyword, deleteBySource as deleteFtsBySource, deleteById as deleteFtsById, clearIndex as clearFtsIndex, getDocumentCount, closeDb as closeFtsDb, } from "./ftsIndex.js";
|
|
8
|
+
// Hybrid search
|
|
9
|
+
export { hybridSearch, searchVector, searchByKeyword, } from "./hybridSearch.js";
|
|
10
|
+
// Indexer
|
|
11
|
+
export { indexFile, indexMainMemory, indexDailyMemories, indexConversation, reindexAll, } from "./indexer.js";
|
package/dist/memory/indexer.js
CHANGED
|
@@ -1,39 +1,166 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 메모리 인덱서 모듈
|
|
3
|
-
*
|
|
3
|
+
* 벡터 저장소와 FTS 인덱스 모두 업데이트합니다.
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import * as fs from "fs/promises";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { invalidateCache, loadAllMemoryChunks } from "./vectorStore.js";
|
|
8
|
+
import { indexTextBatch, clearIndex as clearFtsIndex, getDocumentCount } from "./ftsIndex.js";
|
|
9
|
+
import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
|
|
10
|
+
/**
|
|
11
|
+
* 텍스트를 청크로 분할합니다.
|
|
12
|
+
*/
|
|
13
|
+
function splitIntoChunks(text, source) {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let chunkIndex = 0;
|
|
16
|
+
// ## 헤더로 분할
|
|
17
|
+
const sections = text.split(/(?=^## )/m);
|
|
18
|
+
for (const section of sections) {
|
|
19
|
+
const trimmed = section.trim();
|
|
20
|
+
if (!trimmed || trimmed.length < 20)
|
|
21
|
+
continue;
|
|
22
|
+
// 청크가 너무 길면 추가로 분할
|
|
23
|
+
if (trimmed.length > 500) {
|
|
24
|
+
const lines = trimmed.split("\n");
|
|
25
|
+
let currentChunk = "";
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (currentChunk.length + line.length > 500) {
|
|
28
|
+
if (currentChunk.trim()) {
|
|
29
|
+
chunks.push({
|
|
30
|
+
id: `${source}:${chunkIndex++}`,
|
|
31
|
+
text: currentChunk.trim(),
|
|
32
|
+
source,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
currentChunk = line;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
currentChunk += "\n" + line;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (currentChunk.trim()) {
|
|
42
|
+
chunks.push({
|
|
43
|
+
id: `${source}:${chunkIndex++}`,
|
|
44
|
+
text: currentChunk.trim(),
|
|
45
|
+
source,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
chunks.push({
|
|
51
|
+
id: `${source}:${chunkIndex++}`,
|
|
52
|
+
text: trimmed,
|
|
53
|
+
source,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return chunks;
|
|
11
58
|
}
|
|
12
|
-
|
|
59
|
+
/**
|
|
60
|
+
* 단일 파일 인덱싱 (캐시 무효화 + FTS 업데이트)
|
|
61
|
+
*/
|
|
62
|
+
export async function indexFile(filePath, source) {
|
|
63
|
+
try {
|
|
64
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
65
|
+
const chunks = splitIntoChunks(content, source);
|
|
66
|
+
// FTS 인덱스 업데이트
|
|
67
|
+
const ftsEntries = chunks.map(c => ({
|
|
68
|
+
id: c.id,
|
|
69
|
+
source: c.source,
|
|
70
|
+
text: c.text,
|
|
71
|
+
}));
|
|
72
|
+
indexTextBatch(ftsEntries);
|
|
73
|
+
// 벡터 캐시 무효화
|
|
74
|
+
invalidateCache();
|
|
75
|
+
return chunks.length;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* MEMORY.md 인덱싱
|
|
83
|
+
*/
|
|
13
84
|
export async function indexMainMemory() {
|
|
14
|
-
|
|
15
|
-
return
|
|
85
|
+
const memoryPath = getWorkspaceFilePath("MEMORY.md");
|
|
86
|
+
return indexFile(memoryPath, "MEMORY");
|
|
16
87
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
88
|
+
/**
|
|
89
|
+
* 일일 메모리 파일들 인덱싱
|
|
90
|
+
*/
|
|
91
|
+
export async function indexDailyMemories(days = 30) {
|
|
92
|
+
const memoryDir = getMemoryDirPath();
|
|
93
|
+
let totalChunks = 0;
|
|
94
|
+
try {
|
|
95
|
+
const files = await fs.readdir(memoryDir);
|
|
96
|
+
const mdFiles = files
|
|
97
|
+
.filter(f => f.endsWith(".md") && !f.startsWith("."))
|
|
98
|
+
.sort()
|
|
99
|
+
.reverse()
|
|
100
|
+
.slice(0, days);
|
|
101
|
+
for (const file of mdFiles) {
|
|
102
|
+
const filePath = path.join(memoryDir, file);
|
|
103
|
+
const source = file.replace(".md", "");
|
|
104
|
+
const count = await indexFile(filePath, source);
|
|
105
|
+
totalChunks += count;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// 디렉토리 없음 무시
|
|
110
|
+
}
|
|
111
|
+
return totalChunks;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 대화 기록 인덱싱 (JSONL 형식)
|
|
115
|
+
*/
|
|
116
|
+
export async function indexConversation(conversationId, messages) {
|
|
117
|
+
if (messages.length === 0)
|
|
118
|
+
return 0;
|
|
119
|
+
const ftsEntries = [];
|
|
120
|
+
for (let i = 0; i < messages.length; i++) {
|
|
121
|
+
const msg = messages[i];
|
|
122
|
+
if (!msg.content || msg.content.length < 10)
|
|
123
|
+
continue;
|
|
124
|
+
ftsEntries.push({
|
|
125
|
+
id: `conv:${conversationId}:${i}`,
|
|
126
|
+
source: `conversation:${conversationId}`,
|
|
127
|
+
text: `[${msg.role}] ${msg.content}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (ftsEntries.length > 0) {
|
|
131
|
+
indexTextBatch(ftsEntries);
|
|
132
|
+
}
|
|
133
|
+
return ftsEntries.length;
|
|
21
134
|
}
|
|
22
|
-
|
|
135
|
+
/**
|
|
136
|
+
* 전체 리인덱싱 (벡터 + FTS 모두)
|
|
137
|
+
*/
|
|
23
138
|
export async function reindexAll() {
|
|
24
|
-
console.log(
|
|
139
|
+
console.log("[Indexer] Starting full reindex...");
|
|
140
|
+
// 1. FTS 인덱스 초기화
|
|
141
|
+
clearFtsIndex();
|
|
142
|
+
// 2. 벡터 캐시 무효화 및 로드
|
|
25
143
|
invalidateCache();
|
|
26
|
-
// 캐시 무효화 후 즉시 로드하여 청크 수 반환
|
|
27
|
-
// search를 임시로 호출하여 로드 트리거 (빈 쿼리로)
|
|
28
|
-
const { loadAllMemoryChunks } = await import('./vectorStore.js');
|
|
29
144
|
const chunks = await loadAllMemoryChunks();
|
|
30
|
-
//
|
|
145
|
+
// 3. 모든 청크를 FTS에 인덱싱
|
|
146
|
+
const ftsEntries = chunks.map((chunk, idx) => ({
|
|
147
|
+
id: `${chunk.source}:${idx}`,
|
|
148
|
+
source: chunk.source,
|
|
149
|
+
text: chunk.text,
|
|
150
|
+
}));
|
|
151
|
+
if (ftsEntries.length > 0) {
|
|
152
|
+
indexTextBatch(ftsEntries);
|
|
153
|
+
}
|
|
154
|
+
// 4. 소스별 집계
|
|
31
155
|
const sourceCounts = new Map();
|
|
32
156
|
for (const chunk of chunks) {
|
|
33
157
|
sourceCounts.set(chunk.source, (sourceCounts.get(chunk.source) || 0) + 1);
|
|
34
158
|
}
|
|
159
|
+
const ftsCount = getDocumentCount();
|
|
160
|
+
console.log(`[Indexer] Indexed ${chunks.length} chunks to vector store, ${ftsCount} documents to FTS`);
|
|
35
161
|
return {
|
|
36
162
|
total: chunks.length,
|
|
37
|
-
sources: Array.from(sourceCounts.keys())
|
|
163
|
+
sources: Array.from(sourceCounts.keys()),
|
|
164
|
+
ftsCount,
|
|
38
165
|
};
|
|
39
166
|
}
|