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.
- package/README.md +1 -1
- package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-CH-H9x0E.js} +1 -1
- package/dashboard/dist/assets/index-CqJRvYRL.js +197 -0
- package/dashboard/dist/assets/index-DICm9PNa.css +1 -0
- package/dashboard/dist/index.html +3 -3
- package/lib/cli/SetupService.js +1 -1
- package/lib/core/ast/ProjectGraph.js +160 -0
- package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
- package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +1 -0
- package/lib/external/mcp/handlers/bootstrap.js +33 -36
- package/lib/external/mcp/handlers/skill.js +4 -2
- package/lib/external/mcp/handlers/system.js +3 -3
- package/lib/http/middleware/requestLogger.js +3 -3
- package/lib/http/routes/ai.js +17 -1
- package/lib/http/routes/skills.js +44 -1
- package/lib/infrastructure/cache/GraphCache.js +143 -0
- package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
- package/lib/infrastructure/realtime/RealtimeService.js +14 -2
- package/lib/injection/ServiceContainer.js +114 -2
- package/lib/repository/token/TokenUsageStore.js +162 -0
- package/lib/service/candidate/CandidateService.js +28 -0
- package/lib/service/chat/AnalystAgent.js +25 -14
- package/lib/service/chat/ChatAgent.js +237 -6
- package/lib/service/chat/ContextWindow.js +87 -3
- package/lib/service/chat/HandoffProtocol.js +26 -1
- package/lib/service/chat/ProducerAgent.js +4 -2
- package/lib/service/chat/tools.js +168 -71
- package/lib/service/skills/SignalCollector.js +3 -2
- package/lib/service/spm/SpmService.js +119 -18
- package/package.json +1 -1
- package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
- package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
package/lib/http/routes/ai.js
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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;
|