@yun-zero/claw-memory 0.1.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/.claude/settings.local.json +68 -0
- package/README.md +323 -0
- package/dist/config/llm.d.ts +13 -0
- package/dist/config/llm.d.ts.map +1 -0
- package/dist/config/llm.js +96 -0
- package/dist/config/llm.js.map +1 -0
- package/dist/config/plugin.d.ts +15 -0
- package/dist/config/plugin.d.ts.map +1 -0
- package/dist/config/plugin.js +32 -0
- package/dist/config/plugin.js.map +1 -0
- package/dist/db/entityRepository.d.ts +21 -0
- package/dist/db/entityRepository.d.ts.map +1 -0
- package/dist/db/entityRepository.js +55 -0
- package/dist/db/entityRepository.js.map +1 -0
- package/dist/db/repository.d.ts +22 -0
- package/dist/db/repository.d.ts.map +1 -0
- package/dist/db/repository.js +77 -0
- package/dist/db/repository.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +112 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/todoRepository.d.ts +26 -0
- package/dist/db/todoRepository.d.ts.map +1 -0
- package/dist/db/todoRepository.js +54 -0
- package/dist/db/todoRepository.js.map +1 -0
- package/dist/hooks/bootstrap.d.ts +3 -0
- package/dist/hooks/bootstrap.d.ts.map +1 -0
- package/dist/hooks/bootstrap.js +28 -0
- package/dist/hooks/bootstrap.js.map +1 -0
- package/dist/hooks/message.d.ts +18 -0
- package/dist/hooks/message.d.ts.map +1 -0
- package/dist/hooks/message.js +52 -0
- package/dist/hooks/message.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/tools.d.ts +26 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +360 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +62 -0
- package/dist/plugin.js.map +1 -0
- package/dist/services/entityGraphService.d.ts +87 -0
- package/dist/services/entityGraphService.d.ts.map +1 -0
- package/dist/services/entityGraphService.js +271 -0
- package/dist/services/entityGraphService.js.map +1 -0
- package/dist/services/memory.d.ts +26 -0
- package/dist/services/memory.d.ts.map +1 -0
- package/dist/services/memory.js +281 -0
- package/dist/services/memory.js.map +1 -0
- package/dist/services/memoryIndex.d.ts +34 -0
- package/dist/services/memoryIndex.d.ts.map +1 -0
- package/dist/services/memoryIndex.js +100 -0
- package/dist/services/memoryIndex.js.map +1 -0
- package/dist/services/metadataExtractor.d.ts +16 -0
- package/dist/services/metadataExtractor.d.ts.map +1 -0
- package/dist/services/metadataExtractor.js +75 -0
- package/dist/services/metadataExtractor.js.map +1 -0
- package/dist/services/retrieval.d.ts +24 -0
- package/dist/services/retrieval.d.ts.map +1 -0
- package/dist/services/retrieval.js +40 -0
- package/dist/services/retrieval.js.map +1 -0
- package/dist/services/scheduler.d.ts +122 -0
- package/dist/services/scheduler.d.ts.map +1 -0
- package/dist/services/scheduler.js +434 -0
- package/dist/services/scheduler.js.map +1 -0
- package/dist/services/summarizer.d.ts +43 -0
- package/dist/services/summarizer.d.ts.map +1 -0
- package/dist/services/summarizer.js +252 -0
- package/dist/services/summarizer.js.map +1 -0
- package/dist/services/tagService.d.ts +64 -0
- package/dist/services/tagService.d.ts.map +1 -0
- package/dist/services/tagService.js +281 -0
- package/dist/services/tagService.js.map +1 -0
- package/dist/tools/memory.d.ts +3 -0
- package/dist/tools/memory.d.ts.map +1 -0
- package/dist/tools/memory.js +114 -0
- package/dist/tools/memory.js.map +1 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/docs/plans/2026-03-02-claw-memory-design.md +445 -0
- package/docs/plans/2026-03-02-incremental-summary-design.md +157 -0
- package/docs/plans/2026-03-02-incremental-summary-implementation.md +468 -0
- package/docs/plans/2026-03-02-memory-index-design.md +163 -0
- package/docs/plans/2026-03-02-memory-index-implementation.md +836 -0
- package/docs/plans/2026-03-02-mvp-implementation.md +1703 -0
- package/docs/plans/2026-03-02-testing-implementation.md +395 -0
- package/docs/plans/2026-03-02-testing-plan.md +93 -0
- package/docs/plans/2026-03-03-claw-memory-openclaw-plugin-design.md +285 -0
- package/docs/plans/2026-03-03-claw-memory-plugin-implementation.md +642 -0
- package/docs/plans/2026-03-03-entity-graph-design.md +121 -0
- package/docs/plans/2026-03-03-entity-graph-implementation.md +687 -0
- package/docs/plans/2026-03-03-llm-generic-config-design.md +43 -0
- package/docs/plans/2026-03-03-llm-generic-config-implementation.md +186 -0
- package/docs/plans/2026-03-03-memory-e2e-stress-test-design.md +110 -0
- package/docs/plans/2026-03-03-memory-e2e-stress-test-implementation.md +464 -0
- package/docs/plans/2026-03-03-minimax-llm-fix.md +156 -0
- package/docs/plans/2026-03-03-scheduler-design.md +165 -0
- package/docs/plans/2026-03-03-scheduler-implementation.md +777 -0
- package/docs/plans/2026-03-03-tags-visualization-design.md +73 -0
- package/docs/plans/2026-03-03-tags-visualization-implementation.md +539 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +41 -0
- package/src/config/llm.ts +129 -0
- package/src/config/plugin.ts +47 -0
- package/src/db/entityRepository.ts +80 -0
- package/src/db/repository.ts +106 -0
- package/src/db/schema.ts +121 -0
- package/src/db/todoRepository.ts +76 -0
- package/src/hooks/bootstrap.ts +36 -0
- package/src/hooks/message.ts +84 -0
- package/src/index.ts +50 -0
- package/src/plugin.ts +85 -0
- package/src/services/entityGraphService.ts +367 -0
- package/src/services/memory.ts +338 -0
- package/src/services/memoryIndex.ts +140 -0
- package/src/services/metadataExtractor.ts +89 -0
- package/src/services/retrieval.ts +71 -0
- package/src/services/scheduler.ts +529 -0
- package/src/services/summarizer.ts +318 -0
- package/src/services/tagService.ts +335 -0
- package/src/tools/memory.ts +137 -0
- package/src/types.ts +139 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { MemoryRepository, CreateMemoryInput } from '../db/repository.js';
|
|
7
|
+
import { EntityRepository } from '../db/entityRepository.js';
|
|
8
|
+
import { calculateWeight, DEFAULT_TIME_DECAY, type SearchOptions } from './retrieval.js';
|
|
9
|
+
import { SummarizerService } from './summarizer.js';
|
|
10
|
+
import { generateSummaryWithLLM } from '../config/llm.js';
|
|
11
|
+
import { MetadataExtractor } from './metadataExtractor.js';
|
|
12
|
+
import type { Memory, SaveMemoryInput, GetContextInput, TimeBucket, WeeklyReport, IntegratedSummary } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export class MemoryService {
|
|
15
|
+
private db: Database.Database;
|
|
16
|
+
private memoryRepo: MemoryRepository;
|
|
17
|
+
private entityRepo: EntityRepository;
|
|
18
|
+
private summarizer: SummarizerService;
|
|
19
|
+
private extractor: MetadataExtractor;
|
|
20
|
+
private dataDir: string;
|
|
21
|
+
|
|
22
|
+
constructor(db: Database.Database, dataDir: string = './memories') {
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.memoryRepo = new MemoryRepository(db);
|
|
25
|
+
this.entityRepo = new EntityRepository(db);
|
|
26
|
+
this.summarizer = new SummarizerService(db);
|
|
27
|
+
this.extractor = new MetadataExtractor();
|
|
28
|
+
this.dataDir = dataDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getLatestIntegratedSummary(): IntegratedSummary | null {
|
|
32
|
+
return this.memoryRepo.getLatestIntegratedSummary();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async saveMemory(input: SaveMemoryInput): Promise<Memory> {
|
|
36
|
+
const { content, metadata } = input;
|
|
37
|
+
|
|
38
|
+
// 生成文件路径
|
|
39
|
+
const date = new Date();
|
|
40
|
+
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
|
|
41
|
+
const fileName = `${uuidv4()}.md`;
|
|
42
|
+
const contentPath = join(this.dataDir, datePath, fileName);
|
|
43
|
+
|
|
44
|
+
// 保存内容到文件
|
|
45
|
+
await this.saveContentToFile(contentPath, content);
|
|
46
|
+
|
|
47
|
+
// 获取已有的整体摘要
|
|
48
|
+
const existingSummary = this.getLatestIntegratedSummary();
|
|
49
|
+
|
|
50
|
+
// 调用 LLM 提取元数据(传入已有摘要)
|
|
51
|
+
const extracted = content.trim()
|
|
52
|
+
? await this.extractor.extract(content, existingSummary || undefined)
|
|
53
|
+
: { tags: [], keywords: [], subjects: [], importance: 0.5, summary: '', integratedSummary: existingSummary || { active_areas: [], key_topics: [], recent_summary: '' } };
|
|
54
|
+
|
|
55
|
+
// 合并:LLM 提取的覆盖用户传入的
|
|
56
|
+
const mergedMetadata = {
|
|
57
|
+
tags: extracted.tags,
|
|
58
|
+
keywords: extracted.keywords,
|
|
59
|
+
subjects: extracted.subjects,
|
|
60
|
+
importance: extracted.importance,
|
|
61
|
+
summary: extracted.summary,
|
|
62
|
+
...metadata
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// 创建记忆记录
|
|
66
|
+
const clampedImportance = this.clampImportance(mergedMetadata.importance);
|
|
67
|
+
const memoryInput: CreateMemoryInput = {
|
|
68
|
+
contentPath,
|
|
69
|
+
summary: mergedMetadata.summary || undefined,
|
|
70
|
+
integratedSummary: extracted.integratedSummary,
|
|
71
|
+
importance: clampedImportance ?? 0.5,
|
|
72
|
+
tokenCount: this.estimateTokens(content)
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const memory = this.memoryRepo.create(memoryInput);
|
|
76
|
+
|
|
77
|
+
// 处理实体关联
|
|
78
|
+
await this.processEntities(memory.id, mergedMetadata);
|
|
79
|
+
|
|
80
|
+
return memory;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async searchMemory(options: SearchOptions): Promise<Memory[]> {
|
|
84
|
+
const { query, timeRange, tags, limit = 10 } = options;
|
|
85
|
+
|
|
86
|
+
// 构建时间过滤条件
|
|
87
|
+
const dateFilter = this.buildDateFilter(timeRange);
|
|
88
|
+
|
|
89
|
+
// 获取所有候选记忆
|
|
90
|
+
let memories = this.memoryRepo.findAll(100);
|
|
91
|
+
|
|
92
|
+
// 应用查询过滤(如果提供了查询字符串)
|
|
93
|
+
if (query && query.trim() !== '') {
|
|
94
|
+
const searchTerm = query.toLowerCase();
|
|
95
|
+
memories = memories.filter(m => {
|
|
96
|
+
const summaryMatch = m.summary?.toLowerCase().includes(searchTerm);
|
|
97
|
+
// 读取内容文件进行匹配
|
|
98
|
+
let contentMatch = false;
|
|
99
|
+
try {
|
|
100
|
+
const content = this.readContentFromFile(m.contentPath);
|
|
101
|
+
contentMatch = content.toLowerCase().includes(searchTerm);
|
|
102
|
+
} catch {
|
|
103
|
+
// 如果读取失败,忽略内容匹配
|
|
104
|
+
}
|
|
105
|
+
return summaryMatch || contentMatch;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 应用时间过滤
|
|
110
|
+
if (dateFilter) {
|
|
111
|
+
memories = memories.filter(m => {
|
|
112
|
+
const created = m.createdAt.toISOString().split('T')[0];
|
|
113
|
+
return created >= dateFilter.start && created <= dateFilter.end;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 计算权重并排序
|
|
118
|
+
const weightedMemories = memories.map(memory => ({
|
|
119
|
+
memory,
|
|
120
|
+
weight: calculateWeight({
|
|
121
|
+
entityMatch: 0,
|
|
122
|
+
timeDecay: DEFAULT_TIME_DECAY,
|
|
123
|
+
memoryDate: memory.createdAt.toISOString().split('T')[0],
|
|
124
|
+
tagMatch: 0,
|
|
125
|
+
importance: memory.importance
|
|
126
|
+
})
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
// 按权重排序
|
|
130
|
+
weightedMemories.sort((a, b) => b.weight - a.weight);
|
|
131
|
+
|
|
132
|
+
// 返回结果
|
|
133
|
+
return weightedMemories.slice(0, limit).map(w => w.memory);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async getContext(input: GetContextInput): Promise<string> {
|
|
137
|
+
const { query, maxTokens = 8000 } = input;
|
|
138
|
+
|
|
139
|
+
const memories = await this.searchMemory({
|
|
140
|
+
query,
|
|
141
|
+
limit: 20,
|
|
142
|
+
maxTokens
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 累积内容直到达到 token 限制
|
|
146
|
+
let totalTokens = 0;
|
|
147
|
+
const contextParts: string[] = [];
|
|
148
|
+
|
|
149
|
+
for (const memory of memories) {
|
|
150
|
+
const content = this.readContentFromFile(memory.contentPath);
|
|
151
|
+
const tokens = this.estimateTokens(content);
|
|
152
|
+
|
|
153
|
+
if (totalTokens + tokens > maxTokens) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
contextParts.push(content);
|
|
158
|
+
totalTokens += tokens;
|
|
159
|
+
|
|
160
|
+
// 更新访问计数
|
|
161
|
+
this.memoryRepo.updateLastAccessed(memory.id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return contextParts.join('\n\n---\n\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getSummary(period: 'day' | 'week' | 'month', date?: string): Promise<TimeBucket> {
|
|
168
|
+
const targetDate = date ? new Date(date) : new Date();
|
|
169
|
+
const { startDate, endDate } = this.calculatePeriodRange(period, targetDate);
|
|
170
|
+
|
|
171
|
+
// 聚合数据
|
|
172
|
+
const report: WeeklyReport = await this.summarizer.aggregateMemories(startDate, endDate);
|
|
173
|
+
|
|
174
|
+
// 生成 LLM 总结
|
|
175
|
+
try {
|
|
176
|
+
const reportString = this.summarizer.reportToString(report);
|
|
177
|
+
report.summary = await generateSummaryWithLLM(reportString);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.warn('Failed to generate LLM summary:', error);
|
|
180
|
+
report.summary = '总结生成失败,请配置有效的 LLM API Key。';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 保存到 time_buckets 表
|
|
184
|
+
this.saveTimeBucket(startDate, report);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
date: startDate,
|
|
188
|
+
memoryCount: report.basic.totalMemories,
|
|
189
|
+
summary: report.summary,
|
|
190
|
+
summaryGeneratedAt: new Date(),
|
|
191
|
+
keyTopics: report.topics.keyTopics,
|
|
192
|
+
createdAt: new Date()
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private calculatePeriodRange(period: 'day' | 'week' | 'month', date: Date): { startDate: string; endDate: string } {
|
|
197
|
+
const endDate = date.toISOString().split('T')[0];
|
|
198
|
+
const startDate = new Date(date);
|
|
199
|
+
|
|
200
|
+
switch (period) {
|
|
201
|
+
case 'day':
|
|
202
|
+
// 返回当天
|
|
203
|
+
break;
|
|
204
|
+
case 'week':
|
|
205
|
+
startDate.setDate(startDate.getDate() - 7);
|
|
206
|
+
break;
|
|
207
|
+
case 'month':
|
|
208
|
+
startDate.setMonth(startDate.getMonth() - 1);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
startDate: startDate.toISOString().split('T')[0],
|
|
214
|
+
endDate
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private saveTimeBucket(date: string, report: WeeklyReport): void {
|
|
219
|
+
const stmt = this.db.prepare(`
|
|
220
|
+
INSERT OR REPLACE INTO time_buckets (date, memory_count, summary, summary_generated_at, key_topics, created_at)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
222
|
+
`);
|
|
223
|
+
|
|
224
|
+
stmt.run(
|
|
225
|
+
date,
|
|
226
|
+
report.basic.totalMemories,
|
|
227
|
+
report.summary,
|
|
228
|
+
new Date().toISOString(),
|
|
229
|
+
JSON.stringify(report.topics.keyTopics),
|
|
230
|
+
new Date().toISOString()
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async saveContentToFile(path: string, content: string): Promise<void> {
|
|
235
|
+
const dir = dirname(path);
|
|
236
|
+
await mkdir(dir, { recursive: true });
|
|
237
|
+
await writeFile(path, content, 'utf-8');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private readContentFromFile(path: string): string {
|
|
241
|
+
try {
|
|
242
|
+
return readFileSync(path, 'utf-8');
|
|
243
|
+
} catch {
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async processEntities(memoryId: string, metadata: SaveMemoryInput['metadata']): Promise<void> {
|
|
249
|
+
// 处理标签
|
|
250
|
+
if (metadata.tags) {
|
|
251
|
+
for (const tag of metadata.tags) {
|
|
252
|
+
const entity = this.entityRepo.findOrCreate({
|
|
253
|
+
name: tag,
|
|
254
|
+
type: 'tag',
|
|
255
|
+
level: tag.split('/').length - 1
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
this.db.prepare(`
|
|
259
|
+
INSERT OR IGNORE INTO memory_entities (memory_id, entity_id, relevance)
|
|
260
|
+
VALUES (?, ?, ?)
|
|
261
|
+
`).run(memoryId, entity.id, 1.0);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 处理主题
|
|
266
|
+
if (metadata.subjects) {
|
|
267
|
+
for (const subject of metadata.subjects) {
|
|
268
|
+
const entity = this.entityRepo.findOrCreate({
|
|
269
|
+
name: subject,
|
|
270
|
+
type: 'subject'
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
this.db.prepare(`
|
|
274
|
+
INSERT OR IGNORE INTO memory_entities (memory_id, entity_id, relevance)
|
|
275
|
+
VALUES (?, ?, ?)
|
|
276
|
+
`).run(memoryId, entity.id, 0.8);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 处理关键词
|
|
281
|
+
if (metadata.keywords) {
|
|
282
|
+
for (const keyword of metadata.keywords) {
|
|
283
|
+
const entity = this.entityRepo.findOrCreate({
|
|
284
|
+
name: keyword,
|
|
285
|
+
type: 'keyword'
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
this.db.prepare(`
|
|
289
|
+
INSERT OR IGNORE INTO memory_entities (memory_id, entity_id, relevance)
|
|
290
|
+
VALUES (?, ?, ?)
|
|
291
|
+
`).run(memoryId, entity.id, 0.6);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private buildDateFilter(timeRange?: SearchOptions['timeRange']): { start: string; end: string } | null {
|
|
297
|
+
if (!timeRange || timeRange === 'all') return null;
|
|
298
|
+
|
|
299
|
+
const now = new Date();
|
|
300
|
+
const end = now.toISOString().split('T')[0];
|
|
301
|
+
let start: string;
|
|
302
|
+
|
|
303
|
+
switch (timeRange) {
|
|
304
|
+
case 'today':
|
|
305
|
+
start = end;
|
|
306
|
+
break;
|
|
307
|
+
case 'week':
|
|
308
|
+
now.setDate(now.getDate() - 7);
|
|
309
|
+
start = now.toISOString().split('T')[0];
|
|
310
|
+
break;
|
|
311
|
+
case 'month':
|
|
312
|
+
now.setMonth(now.getMonth() - 1);
|
|
313
|
+
start = now.toISOString().split('T')[0];
|
|
314
|
+
break;
|
|
315
|
+
case 'year':
|
|
316
|
+
now.setFullYear(now.getFullYear() - 1);
|
|
317
|
+
start = now.toISOString().split('T')[0];
|
|
318
|
+
break;
|
|
319
|
+
default:
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { start, end };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private clampImportance(value: number | undefined): number | undefined {
|
|
327
|
+
if (value === undefined) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
return Math.max(0, Math.min(1, value));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private estimateTokens(text: string): number {
|
|
334
|
+
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
335
|
+
const otherChars = text.length - chineseChars;
|
|
336
|
+
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { TodoRepository } from '../db/todoRepository.js';
|
|
3
|
+
import { MemoryRepository } from '../db/repository.js';
|
|
4
|
+
import { EntityRepository } from '../db/entityRepository.js';
|
|
5
|
+
import type { IntegratedSummary } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export interface MemoryIndexOptions {
|
|
8
|
+
period: 'day' | 'week' | 'month';
|
|
9
|
+
date?: string;
|
|
10
|
+
includeTodos?: boolean;
|
|
11
|
+
includeRecent?: boolean;
|
|
12
|
+
recentLimit?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryIndex {
|
|
16
|
+
period: { start: string; end: string };
|
|
17
|
+
activeAreas: {
|
|
18
|
+
tags: { name: string; count: number }[];
|
|
19
|
+
keywords: string[];
|
|
20
|
+
};
|
|
21
|
+
todos: { id: string; content: string; period: string }[];
|
|
22
|
+
recentActivity: { date: string; summary: string }[];
|
|
23
|
+
integratedSummary?: IntegratedSummary;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getMemoryIndex(db: Database.Database, options: MemoryIndexOptions): Promise<MemoryIndex> {
|
|
27
|
+
const { startDate, endDate } = calculatePeriodRange(options.period, options.date);
|
|
28
|
+
|
|
29
|
+
// 并行获取活跃领域
|
|
30
|
+
const [tags, keywords] = await Promise.all([
|
|
31
|
+
Promise.resolve(getTopTags(db, startDate, endDate, 10)),
|
|
32
|
+
Promise.resolve(getTopKeywords(db, startDate, endDate, 10))
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// 并行获取待办和最近动态
|
|
36
|
+
const [todos, recentActivity] = await Promise.all([
|
|
37
|
+
options.includeTodos
|
|
38
|
+
? getTodos(db, options.period, endDate)
|
|
39
|
+
: Promise.resolve([]),
|
|
40
|
+
options.includeRecent
|
|
41
|
+
? getRecentActivity(db, startDate, endDate, options.recentLimit || 5)
|
|
42
|
+
: Promise.resolve([])
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// 获取缓存的整体摘要(从最新的 memory)
|
|
46
|
+
const memoryRepo = new MemoryRepository(db);
|
|
47
|
+
const rawSummary = memoryRepo.getLatestIntegratedSummary();
|
|
48
|
+
// Convert null to undefined to match the interface type
|
|
49
|
+
const integratedSummary = rawSummary === null ? undefined : rawSummary;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
period: { start: startDate, end: endDate },
|
|
53
|
+
activeAreas: { tags, keywords },
|
|
54
|
+
todos,
|
|
55
|
+
recentActivity,
|
|
56
|
+
integratedSummary
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getTodos(db: Database.Database, period: string, endDate: string) {
|
|
61
|
+
const todoRepo = new TodoRepository(db);
|
|
62
|
+
const allTodos = todoRepo.findByPeriod(period, endDate);
|
|
63
|
+
return allTodos
|
|
64
|
+
.filter(t => !t.completedAt)
|
|
65
|
+
.map(t => ({ id: t.id, content: t.content, period: t.period }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function getRecentActivity(db: Database.Database, startDate: string, endDate: string, limit: number) {
|
|
69
|
+
const recentMemories = findMemoriesByDateRange(db, startDate, endDate, limit);
|
|
70
|
+
return recentMemories.map(m => ({
|
|
71
|
+
date: m.createdAt.toISOString().split('T')[0],
|
|
72
|
+
summary: m.summary || ''
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function calculatePeriodRange(period: string, date?: string) {
|
|
77
|
+
const endDate = date || new Date().toISOString().split('T')[0];
|
|
78
|
+
const startDate = new Date(endDate);
|
|
79
|
+
|
|
80
|
+
switch (period) {
|
|
81
|
+
case 'day': break;
|
|
82
|
+
case 'week': startDate.setDate(startDate.getDate() - 7); break;
|
|
83
|
+
case 'month': startDate.setMonth(startDate.getMonth() - 1); break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
startDate: startDate.toISOString().split('T')[0],
|
|
88
|
+
endDate
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getTopTags(db: Database.Database, startDate: string, endDate: string, limit: number) {
|
|
93
|
+
const rows = db.prepare(`
|
|
94
|
+
SELECT e.name, COUNT(me.memory_id) as count
|
|
95
|
+
FROM memory_entities me
|
|
96
|
+
JOIN entities e ON me.entity_id = e.id
|
|
97
|
+
JOIN memories m ON me.memory_id = m.id
|
|
98
|
+
WHERE e.type = 'tag' AND date(m.created_at) BETWEEN ? AND ?
|
|
99
|
+
GROUP BY e.id ORDER BY count DESC LIMIT ?
|
|
100
|
+
`).all(startDate, endDate, limit) as { name: string; count: number }[];
|
|
101
|
+
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getTopKeywords(db: Database.Database, startDate: string, endDate: string, limit: number) {
|
|
106
|
+
const rows = db.prepare(`
|
|
107
|
+
SELECT e.name FROM memory_entities me
|
|
108
|
+
JOIN entities e ON me.entity_id = e.id
|
|
109
|
+
JOIN memories m ON me.memory_id = m.id
|
|
110
|
+
WHERE e.type = 'keyword' AND date(m.created_at) BETWEEN ? AND ?
|
|
111
|
+
GROUP BY e.id ORDER BY COUNT(*) DESC LIMIT ?
|
|
112
|
+
`).all(startDate, endDate, limit) as { name: string }[];
|
|
113
|
+
return rows.map(r => r.name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface MemoryRow {
|
|
117
|
+
id: string;
|
|
118
|
+
contentPath: string;
|
|
119
|
+
summary: string | null;
|
|
120
|
+
createdAt: Date;
|
|
121
|
+
importance: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findMemoriesByDateRange(db: Database.Database, startDate: string, endDate: string, limit: number): MemoryRow[] {
|
|
125
|
+
const rows = db.prepare(`
|
|
126
|
+
SELECT id, content_path, summary, created_at, importance
|
|
127
|
+
FROM memories
|
|
128
|
+
WHERE date(created_at) BETWEEN ? AND ?
|
|
129
|
+
ORDER BY created_at DESC
|
|
130
|
+
LIMIT ?
|
|
131
|
+
`).all(startDate, endDate, limit) as any[];
|
|
132
|
+
|
|
133
|
+
return rows.map(row => ({
|
|
134
|
+
id: row.id,
|
|
135
|
+
contentPath: row.content_path,
|
|
136
|
+
summary: row.summary,
|
|
137
|
+
createdAt: new Date(row.created_at),
|
|
138
|
+
importance: row.importance
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { generateSummaryWithLLM } from '../config/llm.js';
|
|
2
|
+
import type { IntegratedSummary } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export interface ExtractedMetadata {
|
|
5
|
+
tags: string[];
|
|
6
|
+
keywords: string[];
|
|
7
|
+
subjects: string[];
|
|
8
|
+
importance: number;
|
|
9
|
+
summary: string;
|
|
10
|
+
integratedSummary: IntegratedSummary;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const EXTRACTION_PROMPT = `请从以下对话内容中提取结构化元数据,并整合已有的整体摘要:
|
|
14
|
+
|
|
15
|
+
当前对话内容:
|
|
16
|
+
{content}
|
|
17
|
+
|
|
18
|
+
已有整体摘要(请在此基础上增量更新):
|
|
19
|
+
{existing_summary}
|
|
20
|
+
|
|
21
|
+
请以 JSON 格式返回:
|
|
22
|
+
{
|
|
23
|
+
"tags": ["一级分类/二级分类"],
|
|
24
|
+
"keywords": ["关键词1", "关键词2"],
|
|
25
|
+
"subjects": ["主题1"],
|
|
26
|
+
"importance": 0.0-1.0,
|
|
27
|
+
"summary": "当前对话的一句话摘要",
|
|
28
|
+
"integrated_summary": {
|
|
29
|
+
"active_areas": ["领域名 (出现次数)"],
|
|
30
|
+
"key_topics": ["主题1", "主题2"],
|
|
31
|
+
"recent_summary": "整体摘要自然语言描述"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
注意:
|
|
36
|
+
- tags 使用层级结构
|
|
37
|
+
- integrated_summary 需整合历史信息,在已有基础上增加新领域
|
|
38
|
+
- 只返回 JSON,不要其他内容`;
|
|
39
|
+
|
|
40
|
+
export class MetadataExtractor {
|
|
41
|
+
async extract(content: string, existingSummary?: IntegratedSummary): Promise<ExtractedMetadata> {
|
|
42
|
+
let existingSummaryText = '无';
|
|
43
|
+
if (existingSummary) {
|
|
44
|
+
existingSummaryText = `活跃领域: ${existingSummary.active_areas.join(', ')}\n关键词: ${existingSummary.key_topics.join(', ')}\n近期摘要: ${existingSummary.recent_summary}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const prompt = EXTRACTION_PROMPT
|
|
48
|
+
.replace('{content}', content)
|
|
49
|
+
.replace('{existing_summary}', existingSummaryText);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const response = await generateSummaryWithLLM(prompt);
|
|
53
|
+
return this.parseLLMResponse(response);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.warn('LLM extraction failed:', error);
|
|
56
|
+
return this.fallbackExtract(content);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async callLLM(prompt: string): Promise<ExtractedMetadata> {
|
|
61
|
+
const response = await generateSummaryWithLLM(prompt);
|
|
62
|
+
return this.parseLLMResponse(response);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private parseLLMResponse(response: string): ExtractedMetadata {
|
|
66
|
+
try {
|
|
67
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
68
|
+
if (jsonMatch) {
|
|
69
|
+
return JSON.parse(jsonMatch[0]);
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
return this.fallbackExtract('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fallbackExtract(content: string): ExtractedMetadata {
|
|
76
|
+
return {
|
|
77
|
+
tags: [],
|
|
78
|
+
keywords: [],
|
|
79
|
+
subjects: [],
|
|
80
|
+
importance: 0.5,
|
|
81
|
+
summary: content.substring(0, 100),
|
|
82
|
+
integratedSummary: {
|
|
83
|
+
active_areas: [],
|
|
84
|
+
key_topics: [],
|
|
85
|
+
recent_summary: ''
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Memory, Entity } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export interface TimeDecayConfig {
|
|
4
|
+
today: number;
|
|
5
|
+
week: number;
|
|
6
|
+
month: number;
|
|
7
|
+
year: number;
|
|
8
|
+
older: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WeightInput {
|
|
12
|
+
entityMatch: number;
|
|
13
|
+
timeDecay: TimeDecayConfig;
|
|
14
|
+
memoryDate: string;
|
|
15
|
+
tagMatch: number;
|
|
16
|
+
importance: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_TIME_DECAY: TimeDecayConfig = {
|
|
20
|
+
today: 30,
|
|
21
|
+
week: 20,
|
|
22
|
+
month: 10,
|
|
23
|
+
year: 5,
|
|
24
|
+
older: 0
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function calculateWeight(input: WeightInput): number {
|
|
28
|
+
const { entityMatch, timeDecay, memoryDate, tagMatch, importance } = input;
|
|
29
|
+
|
|
30
|
+
// 实体匹配权重 (0-40)
|
|
31
|
+
const entityWeight = Math.min(entityMatch * 10, 40);
|
|
32
|
+
|
|
33
|
+
// 时间衰减权重 (0-30)
|
|
34
|
+
const timeWeight = getTimeWeight(memoryDate, timeDecay);
|
|
35
|
+
|
|
36
|
+
// 标签层级权重 (0-20)
|
|
37
|
+
const tagWeight = Math.min(tagMatch * 2, 20);
|
|
38
|
+
|
|
39
|
+
// 重要性权重 (0-10)
|
|
40
|
+
const importanceWeight = importance * 10;
|
|
41
|
+
|
|
42
|
+
// 总分 = 实体匹配 × 0.4 + 时间衰减 × 0.3 + 标签层级 × 0.2 + 重要性 × 0.1
|
|
43
|
+
// 归一化到 0-100
|
|
44
|
+
const total =
|
|
45
|
+
entityWeight * 0.4 +
|
|
46
|
+
timeWeight * 0.3 +
|
|
47
|
+
tagWeight * 0.2 +
|
|
48
|
+
importanceWeight * 0.1;
|
|
49
|
+
|
|
50
|
+
return Math.round(total * 10) / 10;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getTimeWeight(memoryDate: string, config: TimeDecayConfig): number {
|
|
54
|
+
const today = new Date();
|
|
55
|
+
const memory = new Date(memoryDate);
|
|
56
|
+
const diffDays = Math.floor((today.getTime() - memory.getTime()) / (1000 * 60 * 60 * 24));
|
|
57
|
+
|
|
58
|
+
if (diffDays === 0) return config.today;
|
|
59
|
+
if (diffDays <= 7) return config.week;
|
|
60
|
+
if (diffDays <= 30) return config.month;
|
|
61
|
+
if (diffDays <= 365) return config.year;
|
|
62
|
+
return config.older;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SearchOptions {
|
|
66
|
+
query: string;
|
|
67
|
+
timeRange?: 'today' | 'week' | 'month' | 'year' | 'all';
|
|
68
|
+
tags?: string[];
|
|
69
|
+
limit?: number;
|
|
70
|
+
maxTokens?: number;
|
|
71
|
+
}
|