companionbot 0.9.0 → 0.10.1

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 CHANGED
@@ -62,13 +62,13 @@ export const MODELS = {
62
62
  sonnet: {
63
63
  id: "claude-sonnet-4-20250514",
64
64
  name: "Claude Sonnet 4",
65
- maxTokens: 8192, // 일반 작업
65
+ maxTokens: 16000, // 일반 작업 (must be > thinkingBudget)
66
66
  thinkingBudget: 10000, // 적당한 thinking
67
67
  },
68
68
  opus: {
69
69
  id: "claude-opus-4-20250514",
70
70
  name: "Claude Opus 4",
71
- maxTokens: 16384, // 복잡한 작업
71
+ maxTokens: 64000, // 복잡한 작업 (must be > thinkingBudget)
72
72
  thinkingBudget: 32000, // 깊은 thinking
73
73
  },
74
74
  };
@@ -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
+ }
@@ -1,4 +1,11 @@
1
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';
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";
@@ -1,39 +1,166 @@
1
1
  /**
2
2
  * 메모리 인덱서 모듈
3
- * 현재 구현은 vectorStore가 on-demand로 로드하므로 캐시 무효화만 수행
3
+ * 벡터 저장소와 FTS 인덱스 모두 업데이트합니다.
4
4
  */
5
- import { invalidateCache } from './vectorStore.js';
6
- // 단일 파일 인덱싱 (캐시 무효화)
7
- export async function indexFile(_filePath, _source) {
8
- // vectorStore가 on-demand로 로드하므로 캐시만 무효화
9
- invalidateCache();
10
- return 1;
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
- // MEMORY.md 인덱싱
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
- invalidateCache();
15
- return 1;
85
+ const memoryPath = getWorkspaceFilePath("MEMORY.md");
86
+ return indexFile(memoryPath, "MEMORY");
16
87
  }
17
- // 일일 메모리 파일들 인덱싱
18
- export async function indexDailyMemories(_days = 30) {
19
- invalidateCache();
20
- return 1;
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('[Indexer] Invalidating cache for reindex...');
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
  }
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
2
  import { estimateMessagesTokens, estimateTokens } from "../utils/tokens.js";
3
+ import * as persistence from "./persistence.js";
3
4
  // 세션 설정
4
5
  const MAX_SESSIONS = 100;
5
6
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
@@ -8,6 +9,8 @@ const MAX_HISTORY_TOKENS = 40000; // 히스토리 한도
8
9
  const SUMMARY_THRESHOLD_TOKENS = 25000; // 이 이상이면 요약 시작
9
10
  const MIN_RECENT_MESSAGES = 6; // 최소 유지할 최근 메시지
10
11
  const MAX_PINNED_TOKENS = 5000; // 핀 맥락 최대 토큰
12
+ // 영구 저장 설정
13
+ const MAX_HISTORY_LOAD = 50; // 메모리에 로드할 최대 메시지 수
11
14
  // 세션별 상태 저장
12
15
  const sessions = new Map();
13
16
  // AsyncLocalStorage for chatId context
@@ -37,8 +40,18 @@ function getSession(chatId) {
37
40
  }
38
41
  // 새 세션 생성 전 정리
39
42
  cleanupSessions();
43
+ // 기존 JSONL 파일에서 히스토리 로드
44
+ const persistedMessages = persistence.loadHistorySync(chatId, MAX_HISTORY_LOAD);
45
+ const history = persistedMessages.map(pm => ({
46
+ role: pm.role,
47
+ content: pm.content,
48
+ }));
49
+ if (persistedMessages.length > 0) {
50
+ const totalCount = persistence.getHistoryCount(chatId);
51
+ console.log(`[Session] Loaded ${persistedMessages.length}/${totalCount} messages from JSONL for chatId=${chatId}`);
52
+ }
40
53
  const session = {
41
- history: [],
54
+ history,
42
55
  model: "sonnet",
43
56
  lastAccessedAt: now,
44
57
  pinnedContexts: [],
@@ -73,6 +86,23 @@ export function getHistory(chatId) {
73
86
  }
74
87
  return session.history;
75
88
  }
89
+ /**
90
+ * 메시지 추가 (메모리 + JSONL 파일 동기화)
91
+ */
92
+ export function addMessage(chatId, role, content) {
93
+ const history = getHistory(chatId);
94
+ history.push({ role, content });
95
+ // JSONL 파일에도 영구 저장
96
+ persistence.appendMessage(chatId, role, content);
97
+ }
98
+ /**
99
+ * 여러 메시지 추가 (배치)
100
+ */
101
+ export function addMessages(chatId, messages) {
102
+ for (const msg of messages) {
103
+ addMessage(chatId, msg.role, msg.content);
104
+ }
105
+ }
76
106
  /**
77
107
  * 핀된 맥락 가져오기
78
108
  */
@@ -292,10 +322,11 @@ export function clearHistory(chatId) {
292
322
  }
293
323
  }
294
324
  /**
295
- * 완전 초기화 (핀 포함)
325
+ * 완전 초기화 (핀 포함 + JSONL 파일 삭제)
296
326
  */
297
327
  export function clearSession(chatId) {
298
328
  sessions.delete(chatId);
329
+ persistence.deleteSessionFile(chatId);
299
330
  }
300
331
  export function getModel(chatId) {
301
332
  return getSession(chatId).model;
@@ -328,5 +359,8 @@ export function getSessionStats(chatId) {
328
359
  pinnedCount: session.pinnedContexts.length,
329
360
  pinnedTokens: session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0),
330
361
  summaryCount: session.summaryChunks.length,
362
+ totalPersistedCount: persistence.getHistoryCount(chatId),
331
363
  };
332
364
  }
365
+ // Re-export persistence functions for external use
366
+ export { searchHistory, getHistoryCount, sessionFileExists, listSessionFiles, } from "./persistence.js";
@@ -49,7 +49,7 @@ function validateResetToken(chatId, token) {
49
49
  resetTokens.delete(chatId); // 사용 후 삭제
50
50
  return true;
51
51
  }
52
- import { getHistory, clearHistory, getModel, setModel, runWithChatId, getPinnedContexts, pinContext, unpinContext, clearPins, getSessionStats, } from "../../session/state.js";
52
+ import { getHistory, clearHistory, getModel, setModel, runWithChatId, getPinnedContexts, pinContext, unpinContext, clearPins, getSessionStats, addMessage, } from "../../session/state.js";
53
53
  import { hasBootstrap, loadRecentMemories, getWorkspacePath, } from "../../workspace/index.js";
54
54
  import { getSecret, setSecret, deleteSecret } from "../../config/secrets.js";
55
55
  import { getReminders } from "../../reminders/index.js";
@@ -75,14 +75,15 @@ export function registerCommands(bot) {
75
75
  const history = getHistory(chatId);
76
76
  const modelId = getModel(chatId);
77
77
  const systemPrompt = await buildSystemPrompt(modelId);
78
- // 첫 메시지 생성 요청
78
+ // 첫 메시지 생성 요청 (시스템 메시지는 JSONL에 저장 안 함 - 세션 내부용)
79
79
  history.push({
80
80
  role: "user",
81
81
  content: "[시스템: 사용자가 /start를 눌렀습니다. 온보딩을 시작하세요.]",
82
82
  });
83
83
  try {
84
84
  const result = await chat(history, systemPrompt, modelId);
85
- history.push({ role: "assistant", content: result.text });
85
+ // 온보딩 응답도 JSONL에 저장
86
+ addMessage(chatId, "assistant", result.text);
86
87
  await ctx.reply(result.text);
87
88
  }
88
89
  catch (error) {
@@ -706,7 +707,8 @@ export function registerCommands(bot) {
706
707
  const chatId = ctx.chat.id;
707
708
  const stats = getSessionStats(chatId);
708
709
  await ctx.reply(`📊 맥락 상태\n\n` +
709
- `💬 히스토리: ${stats.historyLength}개 메시지 (~${stats.historyTokens} 토큰)\n` +
710
+ `💬 메모리: ${stats.historyLength}개 메시지 (~${stats.historyTokens} 토큰)\n` +
711
+ `💾 저장됨: ${stats.totalPersistedCount}개 (JSONL 파일)\n` +
710
712
  `📌 핀: ${stats.pinnedCount}개 (~${stats.pinnedTokens} 토큰)\n` +
711
713
  `📜 요약: ${stats.summaryCount}개\n\n` +
712
714
  `명령어:\n` +
@@ -1,6 +1,7 @@
1
1
  import { chat, chatSmart } from "../../ai/claude.js";
2
2
  import { recordActivity, recordError } from "../../health/index.js";
3
- import { getHistory, getModel, runWithChatId, trimHistoryByTokens, smartTrimHistory, detectImportantContext, pinContext, } from "../../session/state.js";
3
+ import { getHistory, getModel, runWithChatId, trimHistoryByTokens, smartTrimHistory, detectImportantContext, pinContext, addMessage, } from "../../session/state.js";
4
+ import * as persistence from "../../session/persistence.js";
4
5
  import { updateLastMessageTime } from "../../heartbeat/index.js";
5
6
  import { extractUrls, fetchWebContent, formatUrlContent, buildSystemPrompt, } from "../utils/index.js";
6
7
  import { estimateMessagesTokens } from "../../utils/tokens.js";
@@ -145,7 +146,10 @@ export function registerMessageHandlers(bot) {
145
146
  text: caption,
146
147
  },
147
148
  ];
149
+ // API용 메모리 히스토리에는 이미지 데이터 포함
148
150
  history.push({ role: "user", content: imageContent });
151
+ // JSONL에는 캡션만 저장 (이미지 base64는 너무 큼)
152
+ persistence.appendMessage(chatId, "user", `[이미지] ${caption}`);
149
153
  try {
150
154
  const systemPrompt = await buildSystemPrompt(modelId, history);
151
155
  const result = await chat(history, systemPrompt, modelId);
@@ -157,7 +161,9 @@ export function registerMessageHandlers(bot) {
157
161
  .join("\n");
158
162
  assistantContent = `[도구 사용: ${result.toolsUsed.map(t => t.name).join(", ")}]\n${toolsSummary}\n\n---\n${result.text}`;
159
163
  }
164
+ // 메모리 + JSONL 영구 저장
160
165
  history.push({ role: "assistant", content: assistantContent });
166
+ persistence.appendMessage(chatId, "assistant", assistantContent);
161
167
  // 토큰 기반 히스토리 트리밍
162
168
  trimHistoryByTokens(history);
163
169
  await ctx.reply(result.text);
@@ -177,6 +183,7 @@ export function registerMessageHandlers(bot) {
177
183
  userErrorMsg = "사진을 분석하다가 문제가 생겼어. 다시 보내줄래?";
178
184
  }
179
185
  history.push({ role: "assistant", content: `[응답 실패] ${userErrorMsg}` });
186
+ persistence.appendMessage(chatId, "assistant", `[응답 실패] ${userErrorMsg}`);
180
187
  recordError();
181
188
  console.error(`[Photo] chatId=${chatId} error:`, errorMsg);
182
189
  await ctx.reply(userErrorMsg);
@@ -242,8 +249,8 @@ export function registerMessageHandlers(bot) {
242
249
  messageForHistory = userMessage + "\n\n" + urlRefs.join("\n");
243
250
  }
244
251
  }
245
- // 히스토리에는 간략 버전 저장
246
- history.push({ role: "user", content: messageForHistory });
252
+ // 히스토리에는 간략 버전 저장 + JSONL에 영구 저장
253
+ addMessage(chatId, "user", messageForHistory);
247
254
  try {
248
255
  const systemPrompt = await buildSystemPrompt(modelId, history);
249
256
  // API 호출용 메시지 준비 (URL 전체 내용 포함)
@@ -262,7 +269,8 @@ export function registerMessageHandlers(bot) {
262
269
  // 스트리밍 응답 사용 (실시간 업데이트)
263
270
  const response = await sendStreamingResponse(ctx, messagesForApi, // URL 내용이 포함된 버전
264
271
  systemPrompt, modelId);
265
- history.push({ role: "assistant", content: response });
272
+ // 메모리 + JSONL에 영구 저장
273
+ addMessage(chatId, "assistant", response);
266
274
  // 스마트 트리밍 (요약 포함) - autoCompactIfNeeded 대체
267
275
  const summarizeFn = async (messages) => {
268
276
  const summaryPrompt = "다음 대화를 핵심만 3-4문장으로 요약해. 중요한 정보(이름, 선호도, 약속 등)는 반드시 포함:\n\n" +
@@ -298,8 +306,8 @@ export function registerMessageHandlers(bot) {
298
306
  else {
299
307
  userErrorMsg = `문제가 생겼어: ${errorMsg.slice(0, 100)}`;
300
308
  }
301
- // 에러 메시지를 assistant 응답으로 기록 (히스토리 컨텍스트 유지)
302
- history.push({ role: "assistant", content: `[응답 실패] ${userErrorMsg}` });
309
+ // 에러 메시지를 assistant 응답으로 기록 (히스토리 컨텍스트 유지) + JSONL 저장
310
+ addMessage(chatId, "assistant", `[응답 실패] ${userErrorMsg}`);
303
311
  await ctx.reply(userErrorMsg);
304
312
  }
305
313
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",
@@ -48,6 +48,7 @@
48
48
  "@grammyjs/ratelimiter": "^1.2.1",
49
49
  "@inquirer/prompts": "^8.2.0",
50
50
  "@xenova/transformers": "^2.17.2",
51
+ "better-sqlite3": "^12.6.2",
51
52
  "cheerio": "^1.2.0",
52
53
  "google-auth-library": "^9.15.1",
53
54
  "grammy": "^1.31.0",
@@ -56,6 +57,7 @@
56
57
  "node-cron": "^4.2.1"
57
58
  },
58
59
  "devDependencies": {
60
+ "@types/better-sqlite3": "^7.6.13",
59
61
  "@types/node": "^22.0.0",
60
62
  "@types/node-cron": "^3.0.11",
61
63
  "tsx": "^4.0.0",