autosnippet 2.8.2 → 2.9.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 (32) hide show
  1. package/README.md +1 -1
  2. package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-CH-H9x0E.js} +1 -1
  3. package/dashboard/dist/assets/index-CqJRvYRL.js +197 -0
  4. package/dashboard/dist/assets/index-DICm9PNa.css +1 -0
  5. package/dashboard/dist/index.html +3 -3
  6. package/lib/cli/SetupService.js +1 -1
  7. package/lib/core/ast/ProjectGraph.js +160 -0
  8. package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
  9. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +1 -0
  10. package/lib/external/mcp/handlers/bootstrap.js +33 -36
  11. package/lib/external/mcp/handlers/skill.js +4 -2
  12. package/lib/external/mcp/handlers/system.js +3 -3
  13. package/lib/http/middleware/requestLogger.js +3 -3
  14. package/lib/http/routes/ai.js +17 -1
  15. package/lib/http/routes/skills.js +44 -1
  16. package/lib/infrastructure/cache/GraphCache.js +143 -0
  17. package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
  18. package/lib/infrastructure/realtime/RealtimeService.js +14 -2
  19. package/lib/injection/ServiceContainer.js +114 -2
  20. package/lib/repository/token/TokenUsageStore.js +162 -0
  21. package/lib/service/candidate/CandidateService.js +28 -0
  22. package/lib/service/chat/AnalystAgent.js +25 -14
  23. package/lib/service/chat/ChatAgent.js +237 -6
  24. package/lib/service/chat/ContextWindow.js +87 -3
  25. package/lib/service/chat/HandoffProtocol.js +26 -1
  26. package/lib/service/chat/ProducerAgent.js +4 -2
  27. package/lib/service/chat/tools.js +168 -71
  28. package/lib/service/skills/SignalCollector.js +3 -2
  29. package/lib/service/spm/SpmService.js +119 -18
  30. package/package.json +1 -1
  31. package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
  32. package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
@@ -27,7 +27,7 @@ function getChatAgent() {
27
27
  */
28
28
  router.get('/providers', asyncHandler(async (req, res) => {
29
29
  const providers = [
30
- { id: 'google', label: 'Google Gemini', defaultModel: 'gemini-2.0-flash' },
30
+ { id: 'google', label: 'Google Gemini', defaultModel: 'gemini-3-flash-preview' },
31
31
  { id: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o' },
32
32
  { id: 'deepseek', label: 'DeepSeek', defaultModel: 'deepseek-chat' },
33
33
  { id: 'claude', label: 'Claude', defaultModel: 'claude-3-5-sonnet-20240620' },
@@ -398,4 +398,20 @@ router.post('/env-config', asyncHandler(async (req, res) => {
398
398
  res.json({ success: true, data: result });
399
399
  }));
400
400
 
401
+ /**
402
+ * GET /api/v1/ai/token-usage
403
+ * 近 7 日 Token 消耗报告(按日 + 按来源 + 总计)
404
+ */
405
+ router.get('/token-usage', asyncHandler(async (req, res) => {
406
+ const container = getServiceContainer();
407
+ let tokenStore;
408
+ try {
409
+ tokenStore = container.get('tokenUsageStore');
410
+ } catch {
411
+ return res.json({ success: true, data: { daily: [], bySource: [], summary: { input_tokens: 0, output_tokens: 0, total_tokens: 0, call_count: 0, avg_per_call: 0 } } });
412
+ }
413
+ const report = tokenStore.getLast7DaysReport();
414
+ res.json({ success: true, data: report });
415
+ }));
416
+
401
417
  export default router;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import express from 'express';
7
7
  import { asyncHandler } from '../middleware/errorHandler.js';
8
- import { listSkills, loadSkill, createSkill, suggestSkills } from '../../external/mcp/handlers/skill.js';
8
+ import { listSkills, loadSkill, createSkill, deleteSkill, updateSkill, suggestSkills } from '../../external/mcp/handlers/skill.js';
9
9
  import { ValidationError } from '../../shared/errors/index.js';
10
10
 
11
11
  const router = express.Router();
@@ -107,4 +107,47 @@ router.post('/', asyncHandler(async (req, res) => {
107
107
  res.status(201).json({ success: true, data: parsed.data });
108
108
  }));
109
109
 
110
+ /**
111
+ * PUT /api/v1/skills/:name
112
+ * 更新项目级 Skill(description / content)
113
+ */
114
+ router.put('/:name', asyncHandler(async (req, res) => {
115
+ const { name } = req.params;
116
+ const { description, content } = req.body;
117
+
118
+ if (!description && !content) {
119
+ throw new ValidationError('At least one of description or content must be provided');
120
+ }
121
+
122
+ const raw = updateSkill(null, { name, description, content });
123
+ const parsed = JSON.parse(raw);
124
+
125
+ if (!parsed.success) {
126
+ const status = parsed.error?.code === 'SKILL_NOT_FOUND' ? 404
127
+ : parsed.error?.code === 'BUILTIN_PROTECTED' ? 403 : 500;
128
+ return res.status(status).json(parsed);
129
+ }
130
+
131
+ res.json({ success: true, data: parsed.data });
132
+ }));
133
+
134
+ /**
135
+ * DELETE /api/v1/skills/:name
136
+ * 删除项目级 Skill
137
+ */
138
+ router.delete('/:name', asyncHandler(async (req, res) => {
139
+ const { name } = req.params;
140
+
141
+ const raw = deleteSkill(null, { name });
142
+ const parsed = JSON.parse(raw);
143
+
144
+ if (!parsed.success) {
145
+ const status = parsed.error?.code === 'SKILL_NOT_FOUND' ? 404
146
+ : parsed.error?.code === 'BUILTIN_PROTECTED' ? 403 : 500;
147
+ return res.status(status).json(parsed);
148
+ }
149
+
150
+ res.json({ success: true, data: parsed.data });
151
+ }));
152
+
110
153
  export default router;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * GraphCache — 基于文件的图数据持久化缓存
3
+ *
4
+ * 功能:
5
+ * 1. 将图数据序列化为 JSON 写入磁盘
6
+ * 2. 基于 contentHash 判断缓存是否有效(Package.swift / 源文件)
7
+ * 3. 支持 SPM 依赖图和 AST ProjectGraph 两种场景
8
+ *
9
+ * 缓存位置: {projectRoot}/.autosnippet/cache/
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+ import Logger from '../logging/Logger.js';
16
+
17
+ export class GraphCache {
18
+ #cacheDir;
19
+ #logger;
20
+
21
+ /**
22
+ * @param {string} projectRoot 项目根目录
23
+ */
24
+ constructor(projectRoot) {
25
+ this.#cacheDir = join(projectRoot, '.autosnippet', 'cache');
26
+ this.#logger = Logger.getInstance();
27
+ }
28
+
29
+ /**
30
+ * 保存缓存
31
+ * @param {string} key 缓存键名(生成 {key}.json)
32
+ * @param {object} data 要缓存的数据
33
+ * @param {object} meta 元信息(含 hash、timestamp 等)
34
+ */
35
+ save(key, data, meta = {}) {
36
+ try {
37
+ if (!existsSync(this.#cacheDir)) {
38
+ mkdirSync(this.#cacheDir, { recursive: true });
39
+ }
40
+ const payload = {
41
+ version: 1,
42
+ savedAt: new Date().toISOString(),
43
+ ...meta,
44
+ data,
45
+ };
46
+ const filePath = join(this.#cacheDir, `${key}.json`);
47
+ writeFileSync(filePath, JSON.stringify(payload), 'utf-8');
48
+ this.#logger.debug(`[GraphCache] saved: ${key} (${JSON.stringify(payload).length} bytes)`);
49
+ } catch (err) {
50
+ this.#logger.warn(`[GraphCache] save failed for ${key}: ${err.message}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 加载缓存
56
+ * @param {string} key 缓存键名
57
+ * @returns {{ data: object, [key: string]: any } | null}
58
+ */
59
+ load(key) {
60
+ try {
61
+ const filePath = join(this.#cacheDir, `${key}.json`);
62
+ if (!existsSync(filePath)) return null;
63
+ const raw = readFileSync(filePath, 'utf-8');
64
+ return JSON.parse(raw);
65
+ } catch (err) {
66
+ this.#logger.warn(`[GraphCache] load failed for ${key}: ${err.message}`);
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 检查缓存是否有效(hash 匹配)
73
+ * @param {string} key 缓存键
74
+ * @param {string} currentHash 当前内容的 hash
75
+ * @returns {boolean}
76
+ */
77
+ isValid(key, currentHash) {
78
+ const cached = this.load(key);
79
+ if (!cached) return false;
80
+ return cached.contentHash === currentHash;
81
+ }
82
+
83
+ /**
84
+ * 删除缓存
85
+ * @param {string} key
86
+ */
87
+ invalidate(key) {
88
+ try {
89
+ const filePath = join(this.#cacheDir, `${key}.json`);
90
+ if (existsSync(filePath)) {
91
+ unlinkSync(filePath);
92
+ this.#logger.debug(`[GraphCache] invalidated: ${key}`);
93
+ }
94
+ } catch (err) {
95
+ this.#logger.warn(`[GraphCache] invalidate failed for ${key}: ${err.message}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 计算文件内容 hash
101
+ * @param {string} filePath 文件绝对路径
102
+ * @returns {string} sha256 hex (前 16 字符)
103
+ */
104
+ computeFileHash(filePath) {
105
+ try {
106
+ const content = readFileSync(filePath, 'utf-8');
107
+ return this.computeContentHash(content);
108
+ } catch {
109
+ return '';
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 计算字符串内容 hash
115
+ * @param {string} content
116
+ * @returns {string} sha256 hex (前 16 字符)
117
+ */
118
+ computeContentHash(content) {
119
+ return createHash('sha256').update(content).digest('hex').substring(0, 16);
120
+ }
121
+
122
+ /**
123
+ * 批量计算文件 hash 映射
124
+ * @param {string[]} filePaths 文件绝对路径数组
125
+ * @param {string} projectRoot 项目根目录
126
+ * @returns {Object<string, string>} { relativePath: hash }
127
+ */
128
+ computeFileHashes(filePaths, projectRoot) {
129
+ const hashes = {};
130
+ for (const fp of filePaths) {
131
+ const rel = relative(projectRoot, fp);
132
+ hashes[rel] = this.computeFileHash(fp);
133
+ }
134
+ return hashes;
135
+ }
136
+
137
+ /**
138
+ * 获取缓存目录路径
139
+ */
140
+ getCacheDir() {
141
+ return this.#cacheDir;
142
+ }
143
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Migration 015: Create token_usage table
3
+ *
4
+ * 持久化 AI 调用的 Token 消耗记录,支持近 7 日消耗趋势查询。
5
+ * 每次 ChatAgent.execute() 完成后写入一条记录。
6
+ */
7
+ export default function migrate(db) {
8
+ db.exec(`
9
+ CREATE TABLE IF NOT EXISTS token_usage (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ timestamp INTEGER NOT NULL,
12
+ source TEXT NOT NULL DEFAULT 'unknown',
13
+ dimension TEXT,
14
+ provider TEXT,
15
+ model TEXT,
16
+ input_tokens INTEGER NOT NULL DEFAULT 0,
17
+ output_tokens INTEGER NOT NULL DEFAULT 0,
18
+ total_tokens INTEGER NOT NULL DEFAULT 0,
19
+ duration_ms INTEGER,
20
+ tool_calls INTEGER DEFAULT 0,
21
+ session_id TEXT
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);
25
+ CREATE INDEX IF NOT EXISTS idx_token_usage_source ON token_usage(source);
26
+ `);
27
+ }
@@ -17,6 +17,8 @@ export class RealtimeService {
17
17
  methods: ['GET', 'POST'],
18
18
  },
19
19
  transports: ['websocket', 'polling'],
20
+ pingInterval: 25000, // 25s 心跳间隔(默认值,显式声明)
21
+ pingTimeout: 20000, // 20s 超时(默认值)
20
22
  });
21
23
 
22
24
  this.setupEventHandlers();
@@ -27,7 +29,7 @@ export class RealtimeService {
27
29
  */
28
30
  setupEventHandlers() {
29
31
  this.io.on('connection', (socket) => {
30
- Logger.info(`[Socket.io] Client connected: ${socket.id}`);
32
+ Logger.debug(`[Socket.io] Client connected: ${socket.id}`);
31
33
 
32
34
  // 加入通知房间
33
35
  socket.on('join-notifications', () => {
@@ -45,7 +47,7 @@ export class RealtimeService {
45
47
 
46
48
  // 处理断开连接
47
49
  socket.on('disconnect', () => {
48
- Logger.info(`[Socket.io] Client disconnected: ${socket.id}`);
50
+ Logger.debug(`[Socket.io] Client disconnected: ${socket.id}`);
49
51
  });
50
52
 
51
53
  // 健康检查
@@ -79,6 +81,16 @@ export class RealtimeService {
79
81
  });
80
82
  }
81
83
 
84
+ /**
85
+ * 广播 Token 用量变化事件(Sidebar 指标刷新用)
86
+ */
87
+ broadcastTokenUsageUpdated() {
88
+ this.io.to('notifications').emit('token-usage-updated', {
89
+ type: 'token_usage_updated',
90
+ timestamp: Date.now(),
91
+ });
92
+ }
93
+
82
94
  /**
83
95
  * 广播食谱创建事件
84
96
  */
@@ -3,6 +3,8 @@ import Logger from '../infrastructure/logging/Logger.js';
3
3
  import AuditStore from '../infrastructure/audit/AuditStore.js';
4
4
  import AuditLogger from '../infrastructure/audit/AuditLogger.js';
5
5
  import Gateway from '../core/gateway/Gateway.js';
6
+ import { readdirSync, statSync } from 'node:fs';
7
+ import { join as pathJoin, relative as pathRelative, extname as pathExtname } from 'node:path';
6
8
 
7
9
  import { CandidateRepositoryImpl } from '../repository/candidate/CandidateRepository.impl.js';
8
10
  import { RecipeRepositoryImpl } from '../repository/recipe/RecipeRepository.impl.js';
@@ -34,6 +36,9 @@ import { ExclusionManager } from '../service/guard/ExclusionManager.js';
34
36
  import { RuleLearner } from '../service/guard/RuleLearner.js';
35
37
  import { ViolationsStore } from '../service/guard/ViolationsStore.js';
36
38
 
39
+ // ─── P1: Token Usage Tracking ─────────────────────────
40
+ import { TokenUsageStore } from '../repository/token/TokenUsageStore.js';
41
+
37
42
  // ─── P2: Quality ──────────────────────────────────────
38
43
  import { QualityScorer } from '../service/quality/QualityScorer.js';
39
44
  import { FeedbackCollector } from '../service/quality/FeedbackCollector.js';
@@ -53,6 +58,7 @@ import { SkillHooks } from '../service/skills/SkillHooks.js';
53
58
 
54
59
  // ─── v3.0: AST ProjectGraph ──────────────────────────
55
60
  import ProjectGraph from '../core/ast/ProjectGraph.js';
61
+ import { GraphCache } from '../infrastructure/cache/GraphCache.js';
56
62
 
57
63
  // ─── P3: Infrastructure ──────────────────────────────
58
64
  import { EventBus } from '../infrastructure/event/EventBus.js';
@@ -450,6 +456,15 @@ export class ServiceContainer {
450
456
  return this.singletons.violationsStore;
451
457
  });
452
458
 
459
+ // Token Usage: 持久化 AI token 消耗
460
+ this.register('tokenUsageStore', () => {
461
+ if (!this.singletons.tokenUsageStore) {
462
+ const db = this.get('database').getDb();
463
+ this.singletons.tokenUsageStore = new TokenUsageStore(db);
464
+ }
465
+ return this.singletons.tokenUsageStore;
466
+ });
467
+
453
468
  // QualityScorer
454
469
  this.register('qualityScorer', () => {
455
470
  if (!this.singletons.qualityScorer) {
@@ -566,7 +581,7 @@ export class ServiceContainer {
566
581
 
567
582
  /**
568
583
  * 构建 ProjectGraph (v3.0 AST 结构图)
569
- * 应在 bootstrap 流程开始前调用一次
584
+ * 优先从磁盘缓存加载,支持 per-file hash 增量更新
570
585
  * @param {string} projectRoot 项目根目录
571
586
  * @param {object} [options] 传递给 ProjectGraph.build() 的选项
572
587
  * @returns {Promise<import('../core/ast/ProjectGraph.js').default|null>}
@@ -575,14 +590,70 @@ export class ServiceContainer {
575
590
  if (this.singletons.projectGraph) {
576
591
  return this.singletons.projectGraph;
577
592
  }
593
+
594
+ const cache = new GraphCache(projectRoot);
595
+ const startTime = Date.now();
596
+
578
597
  try {
598
+ // ── 尝试从缓存恢复 + 增量更新 ──
599
+ const cached = cache.load('project-graph');
600
+ if (cached?.data && cached.fileHashes) {
601
+ const graph = ProjectGraph.fromJSON(cached.data);
602
+ const currentFiles = this.#collectSourceFilePaths(projectRoot, options);
603
+ const oldHashes = cached.fileHashes || {};
604
+
605
+ // 计算差异:新增 / 变更 / 删除
606
+ const changedPaths = [];
607
+ const newHashes = {};
608
+ for (const fp of currentFiles) {
609
+ const rel = pathRelative(projectRoot, fp);
610
+ const h = cache.computeFileHash(fp);
611
+ newHashes[rel] = h;
612
+ if (!oldHashes[rel] || oldHashes[rel] !== h) {
613
+ changedPaths.push(fp);
614
+ }
615
+ }
616
+ const deletedPaths = Object.keys(oldHashes).filter(rel => !newHashes[rel]);
617
+
618
+ if (changedPaths.length === 0 && deletedPaths.length === 0) {
619
+ // 完全命中
620
+ this.singletons.projectGraph = graph;
621
+ this.logger.info(
622
+ `[ServiceContainer] ProjectGraph ⚡ 缓存命中 (${graph.getOverview().totalClasses} classes, ` +
623
+ `${Date.now() - startTime}ms)`
624
+ );
625
+ return graph;
626
+ }
627
+
628
+ // 增量更新
629
+ const diff = await graph.incrementalUpdate(changedPaths, deletedPaths, options);
630
+ this.singletons.projectGraph = graph;
631
+
632
+ // 写回缓存
633
+ cache.save('project-graph', graph.toJSON(), { fileHashes: newHashes });
634
+
635
+ const overview = graph.getOverview();
636
+ this.logger.info(
637
+ `[ServiceContainer] ProjectGraph 增量更新: +${diff.added} ~${diff.updated} -${diff.deleted} ` +
638
+ `(${overview.totalClasses} classes, ${Date.now() - startTime}ms)`
639
+ );
640
+ return graph;
641
+ }
642
+
643
+ // ── 无缓存,全量构建 ──
579
644
  const graph = await ProjectGraph.build(projectRoot, options);
580
645
  this.singletons.projectGraph = graph;
581
646
  const overview = graph.getOverview();
647
+
648
+ // 计算文件 hash 并写入缓存
649
+ const currentFiles = this.#collectSourceFilePaths(projectRoot, options);
650
+ const fileHashes = cache.computeFileHashes(currentFiles, projectRoot);
651
+ cache.save('project-graph', graph.toJSON(), { fileHashes });
652
+
582
653
  this.logger.info(
583
654
  `[ServiceContainer] ProjectGraph built: ${overview.totalClasses} classes, ` +
584
655
  `${overview.totalProtocols} protocols, ${overview.totalCategories} categories ` +
585
- `(${overview.buildTimeMs}ms)`
656
+ `(${overview.buildTimeMs}ms) — 缓存已写入`
586
657
  );
587
658
  return graph;
588
659
  } catch (err) {
@@ -590,6 +661,47 @@ export class ServiceContainer {
590
661
  return null;
591
662
  }
592
663
  }
664
+
665
+ /**
666
+ * 收集项目源码文件路径(用于 hash 计算)
667
+ * @param {string} projectRoot
668
+ * @param {object} options
669
+ * @returns {string[]}
670
+ */
671
+ #collectSourceFilePaths(projectRoot, options = {}) {
672
+ const DEFAULTS_EXT = { '.m': true, '.h': true, '.swift': true };
673
+ const extSet = new Set(options.extensions || Object.keys(DEFAULTS_EXT));
674
+ const excludePatterns = options.excludePatterns || [
675
+ 'Pods/', 'Carthage/', 'node_modules/', '.build/', 'build/',
676
+ 'DerivedData/', 'vendor/', '.git/', '__tests__/', 'Tests/',
677
+ ];
678
+ const maxFiles = options.maxFiles || 500;
679
+ const maxFileSizeBytes = options.maxFileSizeBytes || 500_000;
680
+ const results = [];
681
+
682
+ function walk(dir) {
683
+ if (results.length >= maxFiles) return;
684
+ let entries;
685
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
686
+ for (const entry of entries) {
687
+ if (results.length >= maxFiles) return;
688
+ const fullPath = pathJoin(dir, entry.name);
689
+ const relativePath = pathRelative(projectRoot, fullPath);
690
+ if (excludePatterns.some(p => relativePath.includes(p))) continue;
691
+ if (entry.isDirectory()) {
692
+ walk(fullPath);
693
+ } else if (entry.isFile() && extSet.has(pathExtname(entry.name))) {
694
+ try {
695
+ const stat = statSync(fullPath);
696
+ if (stat.size <= maxFileSizeBytes) results.push(fullPath);
697
+ } catch { /* skip */ }
698
+ }
699
+ }
700
+ }
701
+
702
+ walk(projectRoot);
703
+ return results;
704
+ }
593
705
  }
594
706
 
595
707
  let containerInstance = null;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * TokenUsageStore — Token 消耗持久化存储
3
+ * 写入 AI 调用的 token 用量记录到 SQLite token_usage 表。
4
+ * 提供近 7 日按日/按来源的聚合查询。
5
+ */
6
+
7
+ import Logger from '../../infrastructure/logging/Logger.js';
8
+
9
+ const MAX_ROWS = 10000; // 自动清理: 保留最近 10000 条
10
+
11
+ export class TokenUsageStore {
12
+ #db;
13
+ #logger;
14
+ #insertStmt;
15
+ #pruneStmt;
16
+ #dailyStmt;
17
+ #bySourceStmt;
18
+ #summaryStmt;
19
+ /** @type {{ data: object, expireAt: number } | null} */
20
+ #reportCache = null;
21
+
22
+ /**
23
+ * @param {import('better-sqlite3').Database} db
24
+ */
25
+ constructor(db) {
26
+ this.#db = db;
27
+ this.#logger = Logger.getInstance();
28
+
29
+ // 预编译常用语句
30
+ this.#insertStmt = this.#db.prepare(`
31
+ INSERT INTO token_usage (timestamp, source, dimension, provider, model, input_tokens, output_tokens, total_tokens, duration_ms, tool_calls, session_id)
32
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33
+ `);
34
+ this.#pruneStmt = this.#db.prepare(`
35
+ DELETE FROM token_usage WHERE id NOT IN (
36
+ SELECT id FROM token_usage ORDER BY timestamp DESC LIMIT ?
37
+ )
38
+ `);
39
+ this.#dailyStmt = this.#db.prepare(`
40
+ SELECT
41
+ DATE(timestamp / 1000, 'unixepoch', 'localtime') AS date,
42
+ SUM(input_tokens) AS input_tokens,
43
+ SUM(output_tokens) AS output_tokens,
44
+ SUM(total_tokens) AS total_tokens,
45
+ COUNT(*) AS call_count
46
+ FROM token_usage
47
+ WHERE timestamp >= ?
48
+ GROUP BY date
49
+ ORDER BY date ASC
50
+ `);
51
+ this.#bySourceStmt = this.#db.prepare(`
52
+ SELECT
53
+ source,
54
+ SUM(input_tokens) AS input_tokens,
55
+ SUM(output_tokens) AS output_tokens,
56
+ SUM(total_tokens) AS total_tokens,
57
+ COUNT(*) AS call_count
58
+ FROM token_usage
59
+ WHERE timestamp >= ?
60
+ GROUP BY source
61
+ ORDER BY total_tokens DESC
62
+ `);
63
+ this.#summaryStmt = this.#db.prepare(`
64
+ SELECT
65
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
66
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
67
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
68
+ COUNT(*) AS call_count
69
+ FROM token_usage
70
+ WHERE timestamp >= ?
71
+ `);
72
+ }
73
+
74
+ // ─── 写入 ─────────────────────────────────────────
75
+
76
+ /**
77
+ * 记录一次 AI 调用的 token 消耗
78
+ * @param {{ source: string, dimension?: string, provider?: string, model?: string, inputTokens: number, outputTokens: number, durationMs?: number, toolCalls?: number, sessionId?: string }} record
79
+ */
80
+ record(record) {
81
+ try {
82
+ const now = Date.now();
83
+ const total = (record.inputTokens || 0) + (record.outputTokens || 0);
84
+ if (total === 0) return; // 跳过无消耗的调用
85
+
86
+ this.#insertStmt.run(
87
+ now,
88
+ record.source || 'unknown',
89
+ record.dimension || null,
90
+ record.provider || null,
91
+ record.model || null,
92
+ record.inputTokens || 0,
93
+ record.outputTokens || 0,
94
+ total,
95
+ record.durationMs || null,
96
+ record.toolCalls || 0,
97
+ record.sessionId || null,
98
+ );
99
+
100
+ // 写入后使缓存失效
101
+ this.#reportCache = null;
102
+
103
+ // 定期清理(每 100 次写入检查一次)
104
+ if (Math.random() < 0.01) {
105
+ this.#pruneStmt.run(MAX_ROWS);
106
+ }
107
+ } catch (err) {
108
+ this.#logger.debug('[TokenUsageStore] record failed', { error: err.message });
109
+ }
110
+ }
111
+
112
+ // ─── 查询 ─────────────────────────────────────────
113
+
114
+ /**
115
+ * 近 7 日按日聚合统计
116
+ * @returns {Array<{ date: string, input_tokens: number, output_tokens: number, total_tokens: number, call_count: number }>}
117
+ */
118
+ getLast7DaysDaily() {
119
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
120
+ return this.#dailyStmt.all(sevenDaysAgo);
121
+ }
122
+
123
+ /**
124
+ * 近 7 日按来源 (source) 聚合统计
125
+ * @returns {Array<{ source: string, input_tokens: number, output_tokens: number, total_tokens: number, call_count: number }>}
126
+ */
127
+ getLast7DaysBySource() {
128
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
129
+ return this.#bySourceStmt.all(sevenDaysAgo);
130
+ }
131
+
132
+ /**
133
+ * 近 7 日总计
134
+ * @returns {{ input_tokens: number, output_tokens: number, total_tokens: number, call_count: number, avg_per_call: number }}
135
+ */
136
+ getLast7DaysSummary() {
137
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
138
+ const row = this.#summaryStmt.get(sevenDaysAgo);
139
+ return {
140
+ ...row,
141
+ avg_per_call: row.call_count > 0 ? Math.round(row.total_tokens / row.call_count) : 0,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * 获取完整的 7 日报告(前端一次拉取)
147
+ * 带 10s 内存缓存,避免高频请求重复查询
148
+ */
149
+ getLast7DaysReport() {
150
+ const now = Date.now();
151
+ if (this.#reportCache && now < this.#reportCache.expireAt) {
152
+ return this.#reportCache.data;
153
+ }
154
+ const data = {
155
+ daily: this.getLast7DaysDaily(),
156
+ bySource: this.getLast7DaysBySource(),
157
+ summary: this.getLast7DaysSummary(),
158
+ };
159
+ this.#reportCache = { data, expireAt: now + 10_000 }; // 10s 缓存
160
+ return data;
161
+ }
162
+ }
@@ -869,6 +869,13 @@ Do NOT wrap in markdown code blocks. Return raw JSON only.`;
869
869
  const metadata = { ...this._buildMetadataFromFlat(item), ...extraMeta };
870
870
  const reasoning = this._buildReasoning(item);
871
871
 
872
+ // ── 去重: 同标题候选已存在于 DB 中则跳过(删除后可重新生成) ──
873
+ const title = item.title || metadata.title || '';
874
+ if (title && this._existsCandidateWithTitle(title)) {
875
+ this.logger.info('Skipped duplicate candidate (title already exists)', { title, source });
876
+ return { skipped: true, reason: 'duplicate_title', title };
877
+ }
878
+
872
879
  // 如果 reasoning 为空对象(缺少 whyStandard),生成默认值
873
880
  if (!reasoning.whyStandard) {
874
881
  reasoning.whyStandard = item.rationale || item.summary || item.description || `Submitted via ${source}`;
@@ -968,6 +975,27 @@ Do NOT wrap in markdown code blocks. Return raw JSON only.`;
968
975
  }
969
976
  }
970
977
  }
978
+
979
+ /**
980
+ * 检查是否已存在同标题的候选(DB 中当前存在的)
981
+ * @param {string} title
982
+ * @returns {boolean}
983
+ */
984
+ _existsCandidateWithTitle(title) {
985
+ try {
986
+ const db = this.candidateRepository.db;
987
+ const titleLower = (title || '').toLowerCase().trim();
988
+ if (!titleLower) return false;
989
+
990
+ // metadata_json 中搜索 title 字段
991
+ const row = db.prepare(
992
+ "SELECT id FROM candidates WHERE LOWER(json_extract(metadata_json, '$.title')) = ?"
993
+ ).get(titleLower);
994
+ return !!row;
995
+ } catch {
996
+ return false;
997
+ }
998
+ }
971
999
  }
972
1000
 
973
1001
  export default CandidateService;