@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.
Files changed (131) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/README.md +323 -0
  3. package/dist/config/llm.d.ts +13 -0
  4. package/dist/config/llm.d.ts.map +1 -0
  5. package/dist/config/llm.js +96 -0
  6. package/dist/config/llm.js.map +1 -0
  7. package/dist/config/plugin.d.ts +15 -0
  8. package/dist/config/plugin.d.ts.map +1 -0
  9. package/dist/config/plugin.js +32 -0
  10. package/dist/config/plugin.js.map +1 -0
  11. package/dist/db/entityRepository.d.ts +21 -0
  12. package/dist/db/entityRepository.d.ts.map +1 -0
  13. package/dist/db/entityRepository.js +55 -0
  14. package/dist/db/entityRepository.js.map +1 -0
  15. package/dist/db/repository.d.ts +22 -0
  16. package/dist/db/repository.d.ts.map +1 -0
  17. package/dist/db/repository.js +77 -0
  18. package/dist/db/repository.js.map +1 -0
  19. package/dist/db/schema.d.ts +5 -0
  20. package/dist/db/schema.d.ts.map +1 -0
  21. package/dist/db/schema.js +112 -0
  22. package/dist/db/schema.js.map +1 -0
  23. package/dist/db/todoRepository.d.ts +26 -0
  24. package/dist/db/todoRepository.d.ts.map +1 -0
  25. package/dist/db/todoRepository.js +54 -0
  26. package/dist/db/todoRepository.js.map +1 -0
  27. package/dist/hooks/bootstrap.d.ts +3 -0
  28. package/dist/hooks/bootstrap.d.ts.map +1 -0
  29. package/dist/hooks/bootstrap.js +28 -0
  30. package/dist/hooks/bootstrap.js.map +1 -0
  31. package/dist/hooks/message.d.ts +18 -0
  32. package/dist/hooks/message.d.ts.map +1 -0
  33. package/dist/hooks/message.js +52 -0
  34. package/dist/hooks/message.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +46 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/mcp/tools.d.ts +26 -0
  40. package/dist/mcp/tools.d.ts.map +1 -0
  41. package/dist/mcp/tools.js +360 -0
  42. package/dist/mcp/tools.js.map +1 -0
  43. package/dist/plugin.d.ts +18 -0
  44. package/dist/plugin.d.ts.map +1 -0
  45. package/dist/plugin.js +62 -0
  46. package/dist/plugin.js.map +1 -0
  47. package/dist/services/entityGraphService.d.ts +87 -0
  48. package/dist/services/entityGraphService.d.ts.map +1 -0
  49. package/dist/services/entityGraphService.js +271 -0
  50. package/dist/services/entityGraphService.js.map +1 -0
  51. package/dist/services/memory.d.ts +26 -0
  52. package/dist/services/memory.d.ts.map +1 -0
  53. package/dist/services/memory.js +281 -0
  54. package/dist/services/memory.js.map +1 -0
  55. package/dist/services/memoryIndex.d.ts +34 -0
  56. package/dist/services/memoryIndex.d.ts.map +1 -0
  57. package/dist/services/memoryIndex.js +100 -0
  58. package/dist/services/memoryIndex.js.map +1 -0
  59. package/dist/services/metadataExtractor.d.ts +16 -0
  60. package/dist/services/metadataExtractor.d.ts.map +1 -0
  61. package/dist/services/metadataExtractor.js +75 -0
  62. package/dist/services/metadataExtractor.js.map +1 -0
  63. package/dist/services/retrieval.d.ts +24 -0
  64. package/dist/services/retrieval.d.ts.map +1 -0
  65. package/dist/services/retrieval.js +40 -0
  66. package/dist/services/retrieval.js.map +1 -0
  67. package/dist/services/scheduler.d.ts +122 -0
  68. package/dist/services/scheduler.d.ts.map +1 -0
  69. package/dist/services/scheduler.js +434 -0
  70. package/dist/services/scheduler.js.map +1 -0
  71. package/dist/services/summarizer.d.ts +43 -0
  72. package/dist/services/summarizer.d.ts.map +1 -0
  73. package/dist/services/summarizer.js +252 -0
  74. package/dist/services/summarizer.js.map +1 -0
  75. package/dist/services/tagService.d.ts +64 -0
  76. package/dist/services/tagService.d.ts.map +1 -0
  77. package/dist/services/tagService.js +281 -0
  78. package/dist/services/tagService.js.map +1 -0
  79. package/dist/tools/memory.d.ts +3 -0
  80. package/dist/tools/memory.d.ts.map +1 -0
  81. package/dist/tools/memory.js +114 -0
  82. package/dist/tools/memory.js.map +1 -0
  83. package/dist/types.d.ts +128 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +6 -0
  86. package/dist/types.js.map +1 -0
  87. package/docs/plans/2026-03-02-claw-memory-design.md +445 -0
  88. package/docs/plans/2026-03-02-incremental-summary-design.md +157 -0
  89. package/docs/plans/2026-03-02-incremental-summary-implementation.md +468 -0
  90. package/docs/plans/2026-03-02-memory-index-design.md +163 -0
  91. package/docs/plans/2026-03-02-memory-index-implementation.md +836 -0
  92. package/docs/plans/2026-03-02-mvp-implementation.md +1703 -0
  93. package/docs/plans/2026-03-02-testing-implementation.md +395 -0
  94. package/docs/plans/2026-03-02-testing-plan.md +93 -0
  95. package/docs/plans/2026-03-03-claw-memory-openclaw-plugin-design.md +285 -0
  96. package/docs/plans/2026-03-03-claw-memory-plugin-implementation.md +642 -0
  97. package/docs/plans/2026-03-03-entity-graph-design.md +121 -0
  98. package/docs/plans/2026-03-03-entity-graph-implementation.md +687 -0
  99. package/docs/plans/2026-03-03-llm-generic-config-design.md +43 -0
  100. package/docs/plans/2026-03-03-llm-generic-config-implementation.md +186 -0
  101. package/docs/plans/2026-03-03-memory-e2e-stress-test-design.md +110 -0
  102. package/docs/plans/2026-03-03-memory-e2e-stress-test-implementation.md +464 -0
  103. package/docs/plans/2026-03-03-minimax-llm-fix.md +156 -0
  104. package/docs/plans/2026-03-03-scheduler-design.md +165 -0
  105. package/docs/plans/2026-03-03-scheduler-implementation.md +777 -0
  106. package/docs/plans/2026-03-03-tags-visualization-design.md +73 -0
  107. package/docs/plans/2026-03-03-tags-visualization-implementation.md +539 -0
  108. package/openclaw.plugin.json +11 -0
  109. package/package.json +41 -0
  110. package/src/config/llm.ts +129 -0
  111. package/src/config/plugin.ts +47 -0
  112. package/src/db/entityRepository.ts +80 -0
  113. package/src/db/repository.ts +106 -0
  114. package/src/db/schema.ts +121 -0
  115. package/src/db/todoRepository.ts +76 -0
  116. package/src/hooks/bootstrap.ts +36 -0
  117. package/src/hooks/message.ts +84 -0
  118. package/src/index.ts +50 -0
  119. package/src/plugin.ts +85 -0
  120. package/src/services/entityGraphService.ts +367 -0
  121. package/src/services/memory.ts +338 -0
  122. package/src/services/memoryIndex.ts +140 -0
  123. package/src/services/metadataExtractor.ts +89 -0
  124. package/src/services/retrieval.ts +71 -0
  125. package/src/services/scheduler.ts +529 -0
  126. package/src/services/summarizer.ts +318 -0
  127. package/src/services/tagService.ts +335 -0
  128. package/src/tools/memory.ts +137 -0
  129. package/src/types.ts +139 -0
  130. package/tsconfig.json +20 -0
  131. package/vitest.config.ts +16 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Memory Summarizer Service
3
+ * Aggregates memory data for weekly report generation
4
+ */
5
+
6
+ import Database from 'better-sqlite3';
7
+ import type { Memory, WeeklyReport } from '../types.js';
8
+ import { generateSummaryWithLLM } from '../config/llm.js';
9
+
10
+ export class SummarizerService {
11
+ private db: Database.Database;
12
+
13
+ constructor(db: Database.Database) {
14
+ this.db = db;
15
+ }
16
+
17
+ async aggregateMemories(startDate: string, endDate: string): Promise<WeeklyReport> {
18
+ // 1. 基础统计 - 查询指定日期范围的 memories
19
+ const basicStats = this.getBasicStats(startDate, endDate);
20
+
21
+ // 2. 标签分布 - 查询 memory_entities + entities (type='tag')
22
+ const tagStats = this.getTagStats(startDate, endDate);
23
+
24
+ // 3. 关键词 - 查询 memory_entities + entities (type='keyword')
25
+ const keywordStats = this.getKeywordStats(startDate, endDate);
26
+
27
+ // 4. 重要性分组 - 按 importance 分组
28
+ const importanceStats = this.getImportanceStats(startDate, endDate);
29
+
30
+ // 5. 访问模式 - 按 access_count 和时间排序
31
+ const accessStats = this.getAccessStats(startDate, endDate);
32
+
33
+ // 6. 实体关系 - 查询 entity_relations
34
+ const entityStats = this.getEntityStats(startDate, endDate);
35
+
36
+ return {
37
+ period: { start: startDate, end: endDate },
38
+ basic: basicStats,
39
+ tags: tagStats,
40
+ topics: keywordStats,
41
+ importance: importanceStats,
42
+ access: accessStats,
43
+ entities: entityStats,
44
+ summary: '' // Will be filled by LLM
45
+ };
46
+ }
47
+
48
+ private getBasicStats(startDate: string, endDate: string): WeeklyReport['basic'] {
49
+ const stmt = this.db.prepare(`
50
+ SELECT
51
+ COUNT(*) as totalMemories,
52
+ COALESCE(SUM(token_count), 0) as totalTokens,
53
+ COALESCE(AVG(importance), 0) as avgImportance
54
+ FROM memories
55
+ WHERE date(created_at) >= date(?)
56
+ AND date(created_at) <= date(?)
57
+ `);
58
+
59
+ const result = stmt.get(startDate, endDate) as {
60
+ totalMemories: number;
61
+ totalTokens: number;
62
+ avgImportance: number;
63
+ };
64
+
65
+ return {
66
+ totalMemories: result.totalMemories || 0,
67
+ totalTokens: result.totalTokens || 0,
68
+ avgImportance: parseFloat((result.avgImportance || 0).toFixed(2))
69
+ };
70
+ }
71
+
72
+ private getTagStats(startDate: string, endDate: string): WeeklyReport['tags'] {
73
+ // Get all tags with their counts in the date range
74
+ const stmt = this.db.prepare(`
75
+ SELECT e.name, COUNT(me.memory_id) as count
76
+ FROM memory_entities me
77
+ JOIN entities e ON me.entity_id = e.id
78
+ JOIN memories m ON me.memory_id = m.id
79
+ WHERE e.type = 'tag'
80
+ AND date(m.created_at) >= date(?)
81
+ AND date(m.created_at) <= date(?)
82
+ GROUP BY e.id, e.name
83
+ ORDER BY count DESC
84
+ LIMIT 20
85
+ `);
86
+
87
+ const tags = stmt.all(startDate, endDate) as { name: string; count: number }[];
88
+
89
+ // Build distribution map
90
+ const tagDistribution: Record<string, number> = {};
91
+ for (const tag of tags) {
92
+ tagDistribution[tag.name] = tag.count;
93
+ }
94
+
95
+ return {
96
+ topTags: tags.slice(0, 10),
97
+ tagDistribution
98
+ };
99
+ }
100
+
101
+ private getKeywordStats(startDate: string, endDate: string): WeeklyReport['topics'] {
102
+ const stmt = this.db.prepare(`
103
+ SELECT e.name, COUNT(me.memory_id) as count
104
+ FROM memory_entities me
105
+ JOIN entities e ON me.entity_id = e.id
106
+ JOIN memories m ON me.memory_id = m.id
107
+ WHERE e.type = 'keyword'
108
+ AND date(m.created_at) >= date(?)
109
+ AND date(m.created_at) <= date(?)
110
+ GROUP BY e.id, e.name
111
+ ORDER BY count DESC
112
+ LIMIT 30
113
+ `);
114
+
115
+ const keywordsRaw = stmt.all(startDate, endDate) as { name: string; count: number }[];
116
+
117
+ // Map to expected format with 'word' field
118
+ const keywords = keywordsRaw.map(k => ({ word: k.name, count: k.count }));
119
+
120
+ // Extract key topics (top 5 most frequent)
121
+ const keyTopics = keywordsRaw.slice(0, 5).map(k => k.name);
122
+
123
+ return {
124
+ keywords,
125
+ keyTopics
126
+ };
127
+ }
128
+
129
+ private getImportanceStats(startDate: string, endDate: string): WeeklyReport['importance'] {
130
+ const stmt = this.db.prepare(`
131
+ SELECT id, summary, importance
132
+ FROM memories
133
+ WHERE date(created_at) >= date(?)
134
+ AND date(created_at) <= date(?)
135
+ `);
136
+
137
+ const memories = stmt.all(startDate, endDate) as Pick<Memory, 'id' | 'summary' | 'importance'>[];
138
+
139
+ const highPriority: Pick<Memory, 'id' | 'summary' | 'importance'>[] = [];
140
+ const mediumPriority: Pick<Memory, 'id' | 'summary' | 'importance'>[] = [];
141
+ const lowPriority: Pick<Memory, 'id' | 'summary' | 'importance'>[] = [];
142
+
143
+ for (const memory of memories) {
144
+ if (memory.importance > 0.7) {
145
+ highPriority.push(memory);
146
+ } else if (memory.importance > 0.3) {
147
+ mediumPriority.push(memory);
148
+ } else {
149
+ lowPriority.push(memory);
150
+ }
151
+ }
152
+
153
+ return { highPriority, mediumPriority, lowPriority };
154
+ }
155
+
156
+ private getAccessStats(startDate: string, endDate: string): WeeklyReport['access'] {
157
+ // Most accessed
158
+ const mostAccessedStmt = this.db.prepare(`
159
+ SELECT id, summary, access_count
160
+ FROM memories
161
+ WHERE date(created_at) >= date(?)
162
+ AND date(created_at) <= date(?)
163
+ ORDER BY access_count DESC
164
+ LIMIT 5
165
+ `);
166
+ const mostAccessed = mostAccessedStmt.all(startDate, endDate) as Pick<Memory, 'id' | 'summary' | 'accessCount'>[];
167
+
168
+ // Recently created
169
+ const recentlyCreatedStmt = this.db.prepare(`
170
+ SELECT id, summary, created_at
171
+ FROM memories
172
+ WHERE date(created_at) >= date(?)
173
+ AND date(created_at) <= date(?)
174
+ ORDER BY created_at DESC
175
+ LIMIT 5
176
+ `);
177
+ const recentlyCreated = recentlyCreatedStmt.all(startDate, endDate) as Pick<Memory, 'id' | 'summary' | 'createdAt'>[];
178
+
179
+ // Recently accessed
180
+ const recentlyAccessedStmt = this.db.prepare(`
181
+ SELECT id, summary, last_accessed_at
182
+ FROM memories
183
+ WHERE date(created_at) >= date(?)
184
+ AND date(created_at) <= date(?)
185
+ AND last_accessed_at IS NOT NULL
186
+ ORDER BY last_accessed_at DESC
187
+ LIMIT 5
188
+ `);
189
+ const recentlyAccessed = recentlyAccessedStmt.all(startDate, endDate) as Pick<Memory, 'id' | 'summary' | 'lastAccessedAt'>[];
190
+
191
+ return { mostAccessed, recentlyCreated, recentlyAccessed };
192
+ }
193
+
194
+ private getEntityStats(startDate: string, endDate: string): WeeklyReport['entities'] {
195
+ // Get related entities from entity_relations
196
+ const relatedStmt = this.db.prepare(`
197
+ SELECT e1.name as entity, GROUP_CONCAT(e2.name) as related
198
+ FROM entity_relations er
199
+ JOIN entities e1 ON er.source_id = e1.id
200
+ JOIN entities e2 ON er.target_id = e2.id
201
+ WHERE er.relation_type IN ('related', 'similar', 'co_occur')
202
+ GROUP BY e1.id, e1.name
203
+ LIMIT 10
204
+ `);
205
+ const relatedResults = relatedStmt.all() as { entity: string; related: string }[];
206
+
207
+ const relatedGroups = relatedResults.map(r => ({
208
+ entity: r.entity,
209
+ related: r.related ? r.related.split(',') : []
210
+ }));
211
+
212
+ // Get co-occurring tags
213
+ const coOccurStmt = this.db.prepare(`
214
+ SELECT e1.name as tag1, e2.name as tag2, COUNT(*) as count
215
+ FROM memory_entities me1
216
+ JOIN memory_entities me2 ON me1.memory_id = me2.memory_id AND me1.entity_id < me2.entity_id
217
+ JOIN entities e1 ON me1.entity_id = e1.id
218
+ JOIN entities e2 ON me2.entity_id = e2.id
219
+ JOIN memories m ON me1.memory_id = m.id
220
+ WHERE e1.type = 'tag' AND e2.type = 'tag'
221
+ AND date(m.created_at) >= date(?)
222
+ AND date(m.created_at) <= date(?)
223
+ GROUP BY e1.id, e2.id
224
+ ORDER BY count DESC
225
+ LIMIT 10
226
+ `);
227
+ const coOccurring = coOccurStmt.all(startDate, endDate) as { tag1: string; tag2: string }[];
228
+
229
+ const coOccurringTags: [string, string][] = coOccurring.map(c => [c.tag1, c.tag2]);
230
+
231
+ return { relatedGroups, coOccurringTags };
232
+ }
233
+
234
+ reportToString(report: WeeklyReport): string {
235
+ const lines: string[] = [];
236
+
237
+ lines.push(`📊 周报期间: ${report.period.start} 至 ${report.period.end}`);
238
+ lines.push('');
239
+ lines.push('## 基础统计');
240
+ lines.push(`- 记忆总数: ${report.basic.totalMemories}`);
241
+ lines.push(`- 总 Token 数: ${report.basic.totalTokens}`);
242
+ lines.push(`- 平均重要性: ${report.basic.avgImportance}`);
243
+ lines.push('');
244
+
245
+ if (report.tags.topTags.length > 0) {
246
+ lines.push('## 热门标签');
247
+ for (const tag of report.tags.topTags.slice(0, 5)) {
248
+ lines.push(`- ${tag.name}: ${tag.count} 条`);
249
+ }
250
+ lines.push('');
251
+ }
252
+
253
+ if (report.topics.keyTopics.length > 0) {
254
+ lines.push('## 关键主题');
255
+ lines.push(report.topics.keyTopics.join(', '));
256
+ lines.push('');
257
+ }
258
+
259
+ if (report.importance.highPriority.length > 0) {
260
+ lines.push('## 高优先级记忆');
261
+ for (const m of report.importance.highPriority.slice(0, 3)) {
262
+ lines.push(`- ${m.summary?.substring(0, 50) || '无摘要'}... (重要性: ${m.importance})`);
263
+ }
264
+ lines.push('');
265
+ }
266
+
267
+ return lines.join('\n');
268
+ }
269
+
270
+ /**
271
+ * Generate weekly summary using LLM
272
+ */
273
+ async generateWeeklySummary(report: {
274
+ period: string;
275
+ startDate: string;
276
+ endDate: string;
277
+ memoryCount: number;
278
+ memories?: string[];
279
+ }): Promise<string> {
280
+ const stats = await this.aggregateMemories(report.startDate, report.endDate);
281
+ const statsString = this.reportToString(stats);
282
+
283
+ let prompt = `请为以下周报数据生成一个简洁的中文摘要(100-200字):\n\n${statsString}`;
284
+
285
+ if (report.memories && report.memories.length > 0) {
286
+ prompt += `\n\n## 本周重要记忆内容:\n${report.memories.join('\n---\n')}`;
287
+ }
288
+
289
+ return await generateSummaryWithLLM(prompt);
290
+ }
291
+
292
+ /**
293
+ * Generate monthly summary using LLM
294
+ */
295
+ async generateMonthlySummary(report: {
296
+ period: string;
297
+ startDate: string;
298
+ endDate: string;
299
+ memoryCount: number;
300
+ memories?: string[];
301
+ }): Promise<string> {
302
+ const stats = await this.aggregateMemories(report.startDate, report.endDate);
303
+ const statsString = this.reportToString(stats);
304
+
305
+ let prompt = `请为以下月报数据生成一个简洁的中文摘要(150-300字):\n\n${statsString}`;
306
+
307
+ if (report.memories && report.memories.length > 0) {
308
+ prompt += `\n\n## 本月重要记忆内容:\n${report.memories.join('\n---\n')}`;
309
+ }
310
+
311
+ return await generateSummaryWithLLM(prompt);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Alias for SummarizerService for backwards compatibility
317
+ */
318
+ export { SummarizerService as Summarizer };
@@ -0,0 +1,335 @@
1
+ import Database from 'better-sqlite3';
2
+ import { EntityRepository } from '../db/entityRepository.js';
3
+
4
+ /**
5
+ * TagNode represents a hierarchical tag structure
6
+ */
7
+ export interface TagNode {
8
+ name: string;
9
+ level: number;
10
+ memoryCount: number;
11
+ usageCount: number;
12
+ children: TagNode[];
13
+ }
14
+
15
+ /**
16
+ * TagStats provides aggregated statistics about tags
17
+ */
18
+ export interface TagStats {
19
+ totalTags: number;
20
+ totalMemories: number;
21
+ usageStats: { name: string; count: number }[];
22
+ levelDistribution: Record<number, number>;
23
+ recentlyUsed: { name: string; lastUsedAt: Date }[];
24
+ }
25
+
26
+ /**
27
+ * TagService provides tag management and statistics functionality
28
+ */
29
+ export class TagService {
30
+ private db: Database.Database;
31
+ private entityRepo: EntityRepository;
32
+
33
+ constructor(db: Database.Database) {
34
+ this.db = db;
35
+ this.entityRepo = new EntityRepository(db);
36
+ }
37
+
38
+ /**
39
+ * Get all tags as a hierarchical tree structure with statistics
40
+ */
41
+ async getTagTree(): Promise<{ totalTags: number; maxLevel: number; tree: TagNode[] }> {
42
+ // 1. Get all tags with memory count
43
+ const allTags = this.db.prepare(`
44
+ SELECT e.*, COUNT(me.memory_id) as memory_count
45
+ FROM entities e
46
+ LEFT JOIN memory_entities me ON e.id = me.entity_id
47
+ WHERE e.type = 'tag'
48
+ GROUP BY e.id
49
+ `).all() as any[];
50
+
51
+ // 2. Build mapping: tagId -> TagNode
52
+ const tagMap = new Map<string, TagNode>();
53
+ for (const tag of allTags) {
54
+ tagMap.set(tag.id, {
55
+ name: tag.name,
56
+ level: tag.level,
57
+ memoryCount: tag.memory_count || 0,
58
+ usageCount: tag.memory_count || 0,
59
+ children: []
60
+ });
61
+ }
62
+
63
+ // 3. Build tree structure via parent_id
64
+ const rootTags: TagNode[] = [];
65
+ let maxLevel = 0;
66
+
67
+ for (const tag of allTags) {
68
+ const node = tagMap.get(tag.id)!;
69
+ maxLevel = Math.max(maxLevel, tag.level || 0);
70
+
71
+ if (tag.parent_id && tagMap.has(tag.parent_id)) {
72
+ tagMap.get(tag.parent_id)!.children.push(node);
73
+ } else {
74
+ rootTags.push(node);
75
+ }
76
+ }
77
+
78
+ return { totalTags: allTags.length, maxLevel, tree: rootTags };
79
+ }
80
+
81
+ /**
82
+ * Get tag statistics
83
+ */
84
+ getTagStats(): TagStats {
85
+ // Get all tag entities
86
+ const tags = this.entityRepo.findByType('tag');
87
+
88
+ // Get memory count per tag
89
+ const memoryCountStmt = this.db.prepare(`
90
+ SELECT e.name, COUNT(me.memory_id) as memory_count
91
+ FROM entities e
92
+ LEFT JOIN memory_entities me ON e.id = me.entity_id
93
+ WHERE e.type = 'tag'
94
+ GROUP BY e.id
95
+ `);
96
+ const memoryCounts = memoryCountStmt.all() as { name: string; memory_count: number }[];
97
+
98
+ // Get usage count (relevance sum)
99
+ const usageStmt = this.db.prepare(`
100
+ SELECT e.name, SUM(me.relevance) as usage_count
101
+ FROM entities e
102
+ LEFT JOIN memory_entities me ON e.id = me.entity_id
103
+ WHERE e.type = 'tag'
104
+ GROUP BY e.id
105
+ `);
106
+ const usageCounts = usageStmt.all() as { name: string; usage_count: number }[];
107
+
108
+ // Calculate level distribution
109
+ const levelDistribution: Record<number, number> = {};
110
+ for (const tag of tags) {
111
+ levelDistribution[tag.level] = (levelDistribution[tag.level] || 0) + 1;
112
+ }
113
+
114
+ // Get total memories with tags
115
+ const totalMemoriesStmt = this.db.prepare(`
116
+ SELECT COUNT(DISTINCT memory_id) as count
117
+ FROM memory_entities me
118
+ JOIN entities e ON me.entity_id = e.id
119
+ WHERE e.type = 'tag'
120
+ `);
121
+ const totalMemoriesResult = totalMemoriesStmt.get() as { count: number };
122
+
123
+ // Get recently used tags
124
+ const recentStmt = this.db.prepare(`
125
+ SELECT e.name, MAX(me.created_at) as last_used_at
126
+ FROM entities e
127
+ JOIN memory_entities me ON e.id = me.entity_id
128
+ WHERE e.type = 'tag'
129
+ GROUP BY e.id
130
+ ORDER BY last_used_at DESC
131
+ LIMIT 10
132
+ `);
133
+ const recentlyUsed = recentStmt.all() as { name: string; last_used_at: string }[];
134
+
135
+ // Build usage stats
136
+ const usageStatsMap = new Map(usageCounts.map(u => [u.name, u.usage_count || 0]));
137
+ const usageStats = tags.map(tag => ({
138
+ name: tag.name,
139
+ count: usageStatsMap.get(tag.name) || 0
140
+ })).sort((a, b) => b.count - a.count);
141
+
142
+ return {
143
+ totalTags: tags.length,
144
+ totalMemories: totalMemoriesResult.count,
145
+ usageStats,
146
+ levelDistribution,
147
+ recentlyUsed: recentlyUsed.map(r => ({
148
+ name: r.name,
149
+ lastUsedAt: new Date(r.last_used_at)
150
+ }))
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Generate collapsible tag tree HTML
156
+ */
157
+ generateTreeHtml(data: { totalTags: number; maxLevel: number; tree: TagNode[] }): string {
158
+ const renderNode = (node: TagNode, indent: number = 0): string => {
159
+ const padding = ' '.repeat(indent);
160
+ let html = `${padding}<div class="tag-item" data-level="${node.level}">\n`;
161
+ html += `${padding} <div class="tag-header" onclick="toggle(this)">\n`;
162
+ html += `${padding} <span class="toggle">${node.children.length ? '▶' : '·'}</span>\n`;
163
+ html += `${padding} <span class="tag-name">${node.name}</span>\n`;
164
+ html += `${padding} <span class="tag-count">(${node.memoryCount}条记忆, ${node.usageCount}次使用)</span>\n`;
165
+ html += `${padding} </div>\n`;
166
+
167
+ if (node.children.length > 0) {
168
+ html += `${padding} <div class="tag-children" style="display:none;">\n`;
169
+ for (const child of node.children) {
170
+ html += renderNode(child, indent + 2);
171
+ }
172
+ html += `${padding} </div>\n`;
173
+ }
174
+
175
+ html += `${padding}</div>\n`;
176
+ return html;
177
+ };
178
+
179
+ let html = `<!DOCTYPE html>
180
+ <html>
181
+ <head>
182
+ <meta charset="UTF-8">
183
+ <title>标签树 - ${data.totalTags} 个标签</title>
184
+ <style>
185
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; }
186
+ .tag-item { margin: 4px 0; }
187
+ .tag-header { cursor: pointer; padding: 4px 8px; border-radius: 4px; }
188
+ .tag-header:hover { background: #f0f0f0; }
189
+ .toggle { display: inline-block; width: 20px; color: #666; }
190
+ .tag-name { font-weight: 500; color: #333; }
191
+ .tag-count { color: #999; font-size: 12px; margin-left: 8px; }
192
+ .tag-children { margin-left: 20px; border-left: 1px solid #eee; padding-left: 8px; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <h1>标签树 (${data.totalTags} 个标签, 最大层级: ${data.maxLevel})</h1>
197
+ <script>
198
+ function toggle(el) {
199
+ const children = el.nextElementSibling;
200
+ if (children) children.style.display = children.style.display === 'none' ? 'block' : 'none';
201
+ const arrow = el.querySelector('.toggle');
202
+ if (arrow) arrow.textContent = children.style.display === 'none' ? '▶' : '▼';
203
+ }
204
+ </script>
205
+ `;
206
+
207
+ for (const node of data.tree) {
208
+ html += renderNode(node);
209
+ }
210
+
211
+ html += `</body></html>`;
212
+ return html;
213
+ }
214
+
215
+ /**
216
+ * Generate tag statistics HTML
217
+ */
218
+ generateStatsHtml(stats: TagStats): string {
219
+ const maxUsage = Math.max(...stats.usageStats.map(s => s.count), 1);
220
+
221
+ let html = `<!DOCTYPE html>
222
+ <html>
223
+ <head>
224
+ <meta charset="UTF-8">
225
+ <title>标签统计</title>
226
+ <style>
227
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; }
228
+ h1, h2 { color: #333; }
229
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }
230
+ .stat-card { background: #f9f9f9; padding: 20px; border-radius: 8px; text-align: center; }
231
+ .stat-number { font-size: 36px; font-weight: bold; color: #4a90d9; }
232
+ .stat-label { color: #666; margin-top: 8px; }
233
+ .bar-chart { margin: 20px 0; }
234
+ .bar { background: #4a90d9; color: white; padding: 8px 12px; margin: 4px 0; border-radius: 4px; }
235
+ .recent-list { list-style: none; padding: 0; }
236
+ .recent-list li { padding: 8px; border-bottom: 1px solid #eee; }
237
+ .level-grid { display: flex; gap: 10px; flex-wrap: wrap; }
238
+ .level-badge { background: #e0e0e0; padding: 8px 16px; border-radius: 16px; }
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <h1>标签统计</h1>
243
+
244
+ <div class="stat-grid">
245
+ <div class="stat-card">
246
+ <div class="stat-number">${stats.totalTags}</div>
247
+ <div class="stat-label">总标签数</div>
248
+ </div>
249
+ <div class="stat-card">
250
+ <div class="stat-number">${stats.totalMemories}</div>
251
+ <div class="stat-label">总记忆数</div>
252
+ </div>
253
+ </div>
254
+
255
+ <h2>使用频率排行</h2>
256
+ <div class="bar-chart">
257
+ `;
258
+
259
+ for (const s of stats.usageStats) {
260
+ const width = Math.round((s.count / maxUsage) * 100);
261
+ html += ` <div class="bar" style="width: ${width}%">${s.name} (${s.count})</div>\n`;
262
+ }
263
+
264
+ html += ` </div>
265
+
266
+ <h2>层级分布</h2>
267
+ <div class="level-grid">
268
+ `;
269
+
270
+ for (const [level, count] of Object.entries(stats.levelDistribution)) {
271
+ html += ` <div class="level-badge">Level ${level}: ${count}</div>\n`;
272
+ }
273
+
274
+ html += ` </div>
275
+
276
+ <h2>最近使用</h2>
277
+ <ul class="recent-list">
278
+ `;
279
+
280
+ for (const r of stats.recentlyUsed) {
281
+ const date = new Date(r.lastUsedAt).toLocaleDateString('zh-CN');
282
+ html += ` <li>${r.name} - ${date}</li>\n`;
283
+ }
284
+
285
+ html += ` </ul>
286
+ </body>
287
+ </html>`;
288
+
289
+ return html;
290
+ }
291
+
292
+ /**
293
+ * Build hierarchical tag tree from flat tag list
294
+ */
295
+ private buildTagTree(tags: { name: string; level: number }[]): TagNode[] {
296
+ const tagMap = new Map<string, TagNode>();
297
+ const rootNodes: TagNode[] = [];
298
+
299
+ // First pass: create all nodes
300
+ for (const tag of tags) {
301
+ tagMap.set(tag.name, {
302
+ name: tag.name,
303
+ level: tag.level,
304
+ memoryCount: 0,
305
+ usageCount: 0,
306
+ children: []
307
+ });
308
+ }
309
+
310
+ // Second pass: build hierarchy
311
+ for (const tag of tags) {
312
+ const node = tagMap.get(tag.name)!;
313
+
314
+ if (tag.level === 0) {
315
+ rootNodes.push(node);
316
+ } else {
317
+ // Find parent based on hierarchical naming (e.g., "a/b/c" has parent "a/b")
318
+ const parts = tag.name.split('/');
319
+ if (parts.length > 1) {
320
+ const parentName = parts.slice(0, -1).join('/');
321
+ const parent = tagMap.get(parentName);
322
+ if (parent) {
323
+ parent.children.push(node);
324
+ } else {
325
+ rootNodes.push(node);
326
+ }
327
+ } else {
328
+ rootNodes.push(node);
329
+ }
330
+ }
331
+ }
332
+
333
+ return rootNodes;
334
+ }
335
+ }