companionbot 0.9.0 → 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/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/session/persistence.js +194 -0
- package/dist/session/state.js +36 -2
- package/dist/telegram/handlers/commands.js +6 -4
- package/dist/telegram/handlers/messages.js +14 -6
- package/package.json +3 -1
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/dist/session/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`💬
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|