@massu/core 0.1.0 → 0.1.2
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/LICENSE +71 -0
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +149 -11527
- package/dist/hooks/post-edit-context.js +127 -11493
- package/dist/hooks/post-tool-use.js +169 -11550
- package/dist/hooks/pre-compact.js +149 -11530
- package/dist/hooks/pre-delete-check.js +144 -11523
- package/dist/hooks/quality-event.js +149 -11527
- package/dist/hooks/session-end.js +188 -11570
- package/dist/hooks/session-start.js +159 -11534
- package/dist/hooks/user-prompt.js +149 -11530
- package/package.json +14 -19
- package/src/adr-generator.ts +292 -0
- package/src/analytics.ts +373 -0
- package/src/audit-trail.ts +450 -0
- package/src/backfill-sessions.ts +180 -0
- package/src/cli.ts +105 -0
- package/src/cloud-sync.ts +190 -0
- package/src/commands/doctor.ts +300 -0
- package/src/commands/init.ts +395 -0
- package/src/commands/install-hooks.ts +26 -0
- package/src/config.ts +357 -0
- package/src/cost-tracker.ts +355 -0
- package/src/db.ts +233 -0
- package/src/dependency-scorer.ts +337 -0
- package/src/docs-map.json +100 -0
- package/src/docs-tools.ts +517 -0
- package/src/domains.ts +181 -0
- package/src/hooks/cost-tracker.ts +66 -0
- package/src/hooks/intent-suggester.ts +131 -0
- package/src/hooks/post-edit-context.ts +91 -0
- package/src/hooks/post-tool-use.ts +175 -0
- package/src/hooks/pre-compact.ts +146 -0
- package/src/hooks/pre-delete-check.ts +153 -0
- package/src/hooks/quality-event.ts +127 -0
- package/src/hooks/security-gate.ts +121 -0
- package/src/hooks/session-end.ts +467 -0
- package/src/hooks/session-start.ts +210 -0
- package/src/hooks/user-prompt.ts +91 -0
- package/src/import-resolver.ts +224 -0
- package/src/memory-db.ts +1376 -0
- package/src/memory-tools.ts +391 -0
- package/src/middleware-tree.ts +70 -0
- package/src/observability-tools.ts +343 -0
- package/src/observation-extractor.ts +411 -0
- package/src/page-deps.ts +283 -0
- package/src/prompt-analyzer.ts +332 -0
- package/src/regression-detector.ts +319 -0
- package/src/rules.ts +57 -0
- package/src/schema-mapper.ts +232 -0
- package/src/security-scorer.ts +405 -0
- package/src/security-utils.ts +133 -0
- package/src/sentinel-db.ts +578 -0
- package/src/sentinel-scanner.ts +405 -0
- package/src/sentinel-tools.ts +512 -0
- package/src/sentinel-types.ts +140 -0
- package/src/server.ts +189 -0
- package/src/session-archiver.ts +112 -0
- package/src/session-state-generator.ts +174 -0
- package/src/team-knowledge.ts +407 -0
- package/src/tools.ts +847 -0
- package/src/transcript-parser.ts +458 -0
- package/src/trpc-index.ts +214 -0
- package/src/validate-features-runner.ts +106 -0
- package/src/validation-engine.ts +358 -0
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import type Database from 'better-sqlite3';
|
|
5
|
+
import type { ToolDefinition, ToolResult } from './tools.ts';
|
|
6
|
+
import type { TranscriptEntry } from './transcript-parser.ts';
|
|
7
|
+
import { getConfig } from './config.ts';
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// Cost Attribution Tracking
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
/** Prefix a base tool name with the configured tool prefix. */
|
|
14
|
+
function p(baseName: string): string {
|
|
15
|
+
return `${getConfig().toolPrefix}_${baseName}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default model pricing (Claude models). Can be overridden via config.analytics.cost.models */
|
|
19
|
+
const DEFAULT_MODEL_PRICING: Record<string, { input_per_million: number; output_per_million: number; cache_read_per_million?: number; cache_write_per_million?: number }> = {
|
|
20
|
+
'claude-opus-4-6': { input_per_million: 15.00, output_per_million: 75.00, cache_read_per_million: 1.50, cache_write_per_million: 18.75 },
|
|
21
|
+
'claude-sonnet-4-6': { input_per_million: 3.00, output_per_million: 15.00, cache_read_per_million: 0.30, cache_write_per_million: 3.75 },
|
|
22
|
+
'claude-sonnet-4-5': { input_per_million: 3.00, output_per_million: 15.00, cache_read_per_million: 0.30, cache_write_per_million: 3.75 },
|
|
23
|
+
'claude-haiku-4-5-20251001': { input_per_million: 0.80, output_per_million: 4.00, cache_read_per_million: 0.08, cache_write_per_million: 1.00 },
|
|
24
|
+
'default': { input_per_million: 3.00, output_per_million: 15.00, cache_read_per_million: 0.30, cache_write_per_million: 3.75 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface TokenUsage {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cacheReadTokens: number;
|
|
31
|
+
cacheWriteTokens: number;
|
|
32
|
+
model: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CostResult {
|
|
36
|
+
totalCost: number;
|
|
37
|
+
inputCost: number;
|
|
38
|
+
outputCost: number;
|
|
39
|
+
cacheReadCost: number;
|
|
40
|
+
cacheWriteCost: number;
|
|
41
|
+
currency: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get model pricing from config or defaults.
|
|
46
|
+
*/
|
|
47
|
+
function getModelPricing(): Record<string, { input_per_million: number; output_per_million: number; cache_read_per_million?: number; cache_write_per_million?: number }> {
|
|
48
|
+
return getConfig().analytics?.cost?.models ?? DEFAULT_MODEL_PRICING;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get currency from config or default.
|
|
53
|
+
*/
|
|
54
|
+
function getCurrency(): string {
|
|
55
|
+
return getConfig().analytics?.cost?.currency ?? 'USD';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract token usage from transcript entries.
|
|
60
|
+
*/
|
|
61
|
+
export function extractTokenUsage(entries: TranscriptEntry[]): TokenUsage {
|
|
62
|
+
let inputTokens = 0;
|
|
63
|
+
let outputTokens = 0;
|
|
64
|
+
let cacheReadTokens = 0;
|
|
65
|
+
let cacheWriteTokens = 0;
|
|
66
|
+
let model = 'unknown';
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
// Usage and model are present on raw API responses but not typed on TranscriptMessage
|
|
70
|
+
const msg = entry.message as (Record<string, unknown> | undefined);
|
|
71
|
+
if (entry.type === 'assistant' && msg?.usage) {
|
|
72
|
+
const usage = msg.usage as Record<string, number>;
|
|
73
|
+
inputTokens += usage.input_tokens ?? 0;
|
|
74
|
+
outputTokens += usage.output_tokens ?? 0;
|
|
75
|
+
cacheReadTokens += usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? 0;
|
|
76
|
+
cacheWriteTokens += usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0;
|
|
77
|
+
}
|
|
78
|
+
if (entry.type === 'assistant' && msg?.model) {
|
|
79
|
+
model = msg.model as string;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, model };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Calculate cost from token usage.
|
|
88
|
+
*/
|
|
89
|
+
export function calculateCost(usage: TokenUsage): CostResult {
|
|
90
|
+
const pricing = getModelPricing();
|
|
91
|
+
const modelPricing = pricing[usage.model] ?? pricing['default'] ?? pricing['claude-sonnet-4-5'] ?? { input_per_million: 3.00, output_per_million: 15.00 };
|
|
92
|
+
|
|
93
|
+
const inputCost = (usage.inputTokens / 1_000_000) * modelPricing.input_per_million;
|
|
94
|
+
const outputCost = (usage.outputTokens / 1_000_000) * modelPricing.output_per_million;
|
|
95
|
+
const cacheReadCost = (usage.cacheReadTokens / 1_000_000) * (modelPricing.cache_read_per_million ?? 0);
|
|
96
|
+
const cacheWriteCost = (usage.cacheWriteTokens / 1_000_000) * (modelPricing.cache_write_per_million ?? 0);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost,
|
|
100
|
+
inputCost,
|
|
101
|
+
outputCost,
|
|
102
|
+
cacheReadCost,
|
|
103
|
+
cacheWriteCost,
|
|
104
|
+
currency: getCurrency(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Store session cost data.
|
|
110
|
+
*/
|
|
111
|
+
export function storeSessionCost(
|
|
112
|
+
db: Database.Database,
|
|
113
|
+
sessionId: string,
|
|
114
|
+
usage: TokenUsage,
|
|
115
|
+
cost: CostResult
|
|
116
|
+
): void {
|
|
117
|
+
const totalTokens = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
118
|
+
db.prepare(`
|
|
119
|
+
INSERT INTO session_costs
|
|
120
|
+
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
121
|
+
total_tokens, estimated_cost_usd)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
123
|
+
`).run(
|
|
124
|
+
sessionId, usage.model,
|
|
125
|
+
usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens,
|
|
126
|
+
totalTokens, cost.totalCost
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Backfill cost data from transcript files.
|
|
132
|
+
*/
|
|
133
|
+
export function backfillSessionCosts(db: Database.Database): number {
|
|
134
|
+
// Check for sessions without cost data
|
|
135
|
+
const sessions = db.prepare(`
|
|
136
|
+
SELECT DISTINCT s.session_id
|
|
137
|
+
FROM sessions s
|
|
138
|
+
LEFT JOIN session_costs c ON s.session_id = c.session_id
|
|
139
|
+
WHERE c.session_id IS NULL
|
|
140
|
+
`).all() as Array<{ session_id: string }>;
|
|
141
|
+
|
|
142
|
+
// Backfilling requires transcript data which may not be available
|
|
143
|
+
// Return count of sessions that need backfilling
|
|
144
|
+
return sessions.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================
|
|
148
|
+
// MCP Tool Definitions & Handlers
|
|
149
|
+
// ============================================================
|
|
150
|
+
|
|
151
|
+
export function getCostToolDefinitions(): ToolDefinition[] {
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
name: p('cost_session'),
|
|
155
|
+
description: 'Show cost breakdown for a session. Includes token counts, model pricing, and cost by category.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
session_id: { type: 'string', description: 'Session ID to analyze' },
|
|
160
|
+
},
|
|
161
|
+
required: ['session_id'],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: p('cost_trend'),
|
|
166
|
+
description: 'Cost trend over time. Shows daily/weekly spending and identifies cost drivers.',
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
days: { type: 'number', description: 'Days to look back (default: 30)' },
|
|
171
|
+
group_by: { type: 'string', description: 'Group by: day, week (default: day)' },
|
|
172
|
+
},
|
|
173
|
+
required: [],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: p('cost_feature'),
|
|
178
|
+
description: 'Cost attribution by feature. Shows which features consume the most tokens.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
days: { type: 'number', description: 'Days to look back (default: 30)' },
|
|
183
|
+
},
|
|
184
|
+
required: [],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const COST_BASE_NAMES = new Set(['cost_session', 'cost_trend', 'cost_feature']);
|
|
191
|
+
|
|
192
|
+
export function isCostTool(name: string): boolean {
|
|
193
|
+
const pfx = getConfig().toolPrefix + '_';
|
|
194
|
+
const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
|
|
195
|
+
return COST_BASE_NAMES.has(baseName);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function handleCostToolCall(
|
|
199
|
+
name: string,
|
|
200
|
+
args: Record<string, unknown>,
|
|
201
|
+
memoryDb: Database.Database
|
|
202
|
+
): ToolResult {
|
|
203
|
+
try {
|
|
204
|
+
const pfx = getConfig().toolPrefix + '_';
|
|
205
|
+
const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
|
|
206
|
+
|
|
207
|
+
switch (baseName) {
|
|
208
|
+
case 'cost_session':
|
|
209
|
+
return handleCostSession(args, memoryDb);
|
|
210
|
+
case 'cost_trend':
|
|
211
|
+
return handleCostTrend(args, memoryDb);
|
|
212
|
+
case 'cost_feature':
|
|
213
|
+
return handleCostFeature(args, memoryDb);
|
|
214
|
+
default:
|
|
215
|
+
return text(`Unknown cost tool: ${name}`);
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}\n\nUsage: ${p('cost_session')} { session_id: "..." }, ${p('cost_trend')} { days: 30 }`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleCostSession(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
223
|
+
const sessionId = args.session_id as string;
|
|
224
|
+
if (!sessionId) return text(`Usage: ${p('cost_session')} { session_id: "abc123" } - Show cost breakdown for a specific session.`);
|
|
225
|
+
|
|
226
|
+
const cost = db.prepare(
|
|
227
|
+
'SELECT * FROM session_costs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1'
|
|
228
|
+
).get(sessionId) as Record<string, unknown> | undefined;
|
|
229
|
+
|
|
230
|
+
if (!cost) {
|
|
231
|
+
return text(`No cost data found for session ${sessionId}. Cost data is recorded automatically when sessions end via the session-end hook. If the session is still active, data will appear after it completes. Try: ${p('cost_trend')} { days: 30 } to see aggregate cost data instead.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Recalculate per-category costs from token counts and model
|
|
235
|
+
const modelPricing = getModelPricing();
|
|
236
|
+
const mp = modelPricing[cost.model as string] ?? modelPricing['claude-sonnet-4-5'] ?? { input_per_million: 3.00, output_per_million: 15.00 };
|
|
237
|
+
const inputCost = ((cost.input_tokens as number) / 1_000_000) * mp.input_per_million;
|
|
238
|
+
const outputCost = ((cost.output_tokens as number) / 1_000_000) * mp.output_per_million;
|
|
239
|
+
const cacheReadCost = ((cost.cache_read_tokens as number) / 1_000_000) * (mp.cache_read_per_million ?? 0);
|
|
240
|
+
const cacheWriteCost = ((cost.cache_write_tokens as number) / 1_000_000) * (mp.cache_write_per_million ?? 0);
|
|
241
|
+
|
|
242
|
+
const lines = [
|
|
243
|
+
`## Session Cost: $${(cost.estimated_cost_usd as number).toFixed(4)}`,
|
|
244
|
+
`Model: ${cost.model}`,
|
|
245
|
+
'',
|
|
246
|
+
'### Token Usage',
|
|
247
|
+
`| Type | Tokens | Est. Cost |`,
|
|
248
|
+
`|------|--------|-----------|`,
|
|
249
|
+
`| Input | ${(cost.input_tokens as number).toLocaleString()} | $${inputCost.toFixed(4)} |`,
|
|
250
|
+
`| Output | ${(cost.output_tokens as number).toLocaleString()} | $${outputCost.toFixed(4)} |`,
|
|
251
|
+
`| Cache Read | ${(cost.cache_read_tokens as number).toLocaleString()} | $${cacheReadCost.toFixed(4)} |`,
|
|
252
|
+
`| Cache Write | ${(cost.cache_write_tokens as number).toLocaleString()} | $${cacheWriteCost.toFixed(4)} |`,
|
|
253
|
+
`| **Total** | ${(cost.total_tokens as number).toLocaleString()} | **$${(cost.estimated_cost_usd as number).toFixed(4)}** |`,
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
return text(lines.join('\n'));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function handleCostTrend(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
260
|
+
const days = (args.days as number) ?? 30;
|
|
261
|
+
const groupBy = (args.group_by as string) ?? 'day';
|
|
262
|
+
|
|
263
|
+
if (!['day', 'week'].includes(groupBy)) {
|
|
264
|
+
return text(`Invalid group_by value: "${groupBy}". Use "day" or "week". Example: ${p('cost_trend')} { days: 30, group_by: "week" }`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Use separate prepared statements to avoid SQL string interpolation.
|
|
268
|
+
// dateFormat is validated by the allowlist above, but parameterized SQL
|
|
269
|
+
// is the enterprise-grade approach regardless.
|
|
270
|
+
const sql = groupBy === 'week'
|
|
271
|
+
? `SELECT strftime('%Y-W%W', created_at) as period,
|
|
272
|
+
COUNT(*) as sessions,
|
|
273
|
+
SUM(estimated_cost_usd) as total_cost,
|
|
274
|
+
SUM(total_tokens) as total_tokens,
|
|
275
|
+
AVG(estimated_cost_usd) as avg_cost
|
|
276
|
+
FROM session_costs
|
|
277
|
+
WHERE created_at >= datetime('now', ?)
|
|
278
|
+
GROUP BY period
|
|
279
|
+
ORDER BY period ASC`
|
|
280
|
+
: `SELECT strftime('%Y-%m-%d', created_at) as period,
|
|
281
|
+
COUNT(*) as sessions,
|
|
282
|
+
SUM(estimated_cost_usd) as total_cost,
|
|
283
|
+
SUM(total_tokens) as total_tokens,
|
|
284
|
+
AVG(estimated_cost_usd) as avg_cost
|
|
285
|
+
FROM session_costs
|
|
286
|
+
WHERE created_at >= datetime('now', ?)
|
|
287
|
+
GROUP BY period
|
|
288
|
+
ORDER BY period ASC`;
|
|
289
|
+
|
|
290
|
+
const rows = db.prepare(sql).all(`-${days} days`) as Array<Record<string, unknown>>;
|
|
291
|
+
|
|
292
|
+
if (rows.length === 0) {
|
|
293
|
+
return text(`No cost data found in the last ${days} days. Cost tracking records token usage automatically at session end via hooks. Ensure session-end hooks are configured in your settings. Try: ${p('cost_session')} { session_id: "..." } to check a specific session.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const totalCost = rows.reduce((sum, r) => sum + (r.total_cost as number), 0);
|
|
297
|
+
const totalSessions = rows.reduce((sum, r) => sum + (r.sessions as number), 0);
|
|
298
|
+
|
|
299
|
+
const lines = [
|
|
300
|
+
`## Cost Trend (${days} days)`,
|
|
301
|
+
`Total: $${totalCost.toFixed(2)} across ${totalSessions} sessions`,
|
|
302
|
+
`Average per session: $${(totalCost / totalSessions).toFixed(4)}`,
|
|
303
|
+
'',
|
|
304
|
+
`### By ${groupBy === 'week' ? 'Week' : 'Day'}`,
|
|
305
|
+
`| Period | Sessions | Cost | Avg/Session |`,
|
|
306
|
+
`|--------|----------|------|-------------|`,
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
lines.push(
|
|
311
|
+
`| ${row.period} | ${row.sessions} | $${(row.total_cost as number).toFixed(2)} | $${(row.avg_cost as number).toFixed(4)} |`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return text(lines.join('\n'));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleCostFeature(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
319
|
+
const days = (args.days as number) ?? 30;
|
|
320
|
+
|
|
321
|
+
const rows = db.prepare(`
|
|
322
|
+
SELECT feature_key, SUM(estimated_cost_usd) as total_cost, SUM(tokens_used) as total_tokens, COUNT(*) as entries
|
|
323
|
+
FROM feature_costs
|
|
324
|
+
WHERE created_at >= datetime('now', ?)
|
|
325
|
+
GROUP BY feature_key
|
|
326
|
+
ORDER BY total_cost DESC
|
|
327
|
+
`).all(`-${days} days`) as Array<Record<string, unknown>>;
|
|
328
|
+
|
|
329
|
+
if (rows.length === 0) {
|
|
330
|
+
return text(`No feature cost data found in the last ${days} days. Feature costs are attributed automatically when sessions work on registered features. Ensure features are registered, then costs are tracked per-session. Try: ${p('cost_trend')} { days: 30 } for session-level cost trends instead.`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const totalCost = rows.reduce((sum, r) => sum + (r.total_cost as number), 0);
|
|
334
|
+
|
|
335
|
+
const lines = [
|
|
336
|
+
`## Feature Cost Attribution (${days} days)`,
|
|
337
|
+
`Total: $${totalCost.toFixed(2)} across ${rows.length} features`,
|
|
338
|
+
'',
|
|
339
|
+
'| Feature | Cost | Tokens | Sessions | % of Total |',
|
|
340
|
+
'|---------|------|--------|----------|------------|',
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
for (const row of rows) {
|
|
344
|
+
const pct = totalCost > 0 ? ((row.total_cost as number) / totalCost * 100).toFixed(1) : '0';
|
|
345
|
+
lines.push(
|
|
346
|
+
`| ${row.feature_key} | $${(row.total_cost as number).toFixed(2)} | ${(row.total_tokens as number).toLocaleString()} | ${row.entries} | ${pct}% |`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return text(lines.join('\n'));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function text(content: string): ToolResult {
|
|
354
|
+
return { content: [{ type: 'text', text: content }] };
|
|
355
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
7
|
+
import { getResolvedPaths } from './config.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Connection to CodeGraph's read-only SQLite database.
|
|
11
|
+
* We NEVER write to this DB - it belongs to vanilla CodeGraph.
|
|
12
|
+
*/
|
|
13
|
+
export function getCodeGraphDb(): Database.Database {
|
|
14
|
+
const dbPath = getResolvedPaths().codegraphDbPath;
|
|
15
|
+
if (!existsSync(dbPath)) {
|
|
16
|
+
throw new Error(`CodeGraph database not found at ${dbPath}. Run 'npx @colbymchenry/codegraph sync' first.`);
|
|
17
|
+
}
|
|
18
|
+
const db = new Database(dbPath, { readonly: true });
|
|
19
|
+
db.pragma('journal_mode = WAL');
|
|
20
|
+
return db;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Connection to Massu's supplementary SQLite database.
|
|
25
|
+
* This stores import edges, tRPC mappings, domain classifications, etc.
|
|
26
|
+
*/
|
|
27
|
+
export function getDataDb(): Database.Database {
|
|
28
|
+
const dbPath = getResolvedPaths().dataDbPath;
|
|
29
|
+
const dir = dirname(dbPath);
|
|
30
|
+
if (!existsSync(dir)) {
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
const db = new Database(dbPath);
|
|
34
|
+
db.pragma('journal_mode = WAL');
|
|
35
|
+
db.pragma('foreign_keys = ON');
|
|
36
|
+
initDataSchema(db);
|
|
37
|
+
return db;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function initDataSchema(db: Database.Database): void {
|
|
41
|
+
db.exec(`
|
|
42
|
+
CREATE TABLE IF NOT EXISTS massu_imports (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
source_file TEXT NOT NULL,
|
|
45
|
+
target_file TEXT NOT NULL,
|
|
46
|
+
import_type TEXT NOT NULL CHECK(import_type IN ('named', 'default', 'namespace', 'side_effect', 'dynamic')),
|
|
47
|
+
imported_names TEXT NOT NULL DEFAULT '[]',
|
|
48
|
+
line INTEGER NOT NULL DEFAULT 0
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_massu_imports_source ON massu_imports(source_file);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_massu_imports_target ON massu_imports(target_file);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS massu_trpc_procedures (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
router_file TEXT NOT NULL,
|
|
57
|
+
router_name TEXT NOT NULL,
|
|
58
|
+
procedure_name TEXT NOT NULL,
|
|
59
|
+
procedure_type TEXT NOT NULL CHECK(procedure_type IN ('query', 'mutation')),
|
|
60
|
+
has_ui_caller INTEGER NOT NULL DEFAULT 0
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_massu_trpc_router ON massu_trpc_procedures(router_name);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS massu_trpc_call_sites (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
procedure_id INTEGER NOT NULL,
|
|
68
|
+
file TEXT NOT NULL,
|
|
69
|
+
line INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
call_pattern TEXT NOT NULL,
|
|
71
|
+
FOREIGN KEY (procedure_id) REFERENCES massu_trpc_procedures(id)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_massu_call_sites_proc ON massu_trpc_call_sites(procedure_id);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS massu_page_deps (
|
|
77
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
page_file TEXT NOT NULL,
|
|
79
|
+
route TEXT NOT NULL,
|
|
80
|
+
portal TEXT NOT NULL DEFAULT 'unknown',
|
|
81
|
+
components TEXT NOT NULL DEFAULT '[]',
|
|
82
|
+
hooks TEXT NOT NULL DEFAULT '[]',
|
|
83
|
+
routers TEXT NOT NULL DEFAULT '[]',
|
|
84
|
+
tables_touched TEXT NOT NULL DEFAULT '[]'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_massu_page_deps_page ON massu_page_deps(page_file);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS massu_middleware_tree (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
file TEXT NOT NULL UNIQUE
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS massu_meta (
|
|
95
|
+
key TEXT PRIMARY KEY,
|
|
96
|
+
value TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
-- ============================================================
|
|
100
|
+
-- Sentinel: Feature Registry Tables
|
|
101
|
+
-- ============================================================
|
|
102
|
+
|
|
103
|
+
-- Core feature definition
|
|
104
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel (
|
|
105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
106
|
+
feature_key TEXT UNIQUE NOT NULL,
|
|
107
|
+
domain TEXT NOT NULL,
|
|
108
|
+
subdomain TEXT,
|
|
109
|
+
title TEXT NOT NULL,
|
|
110
|
+
description TEXT,
|
|
111
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
112
|
+
CHECK(status IN ('planned', 'active', 'deprecated', 'removed')),
|
|
113
|
+
priority TEXT DEFAULT 'standard'
|
|
114
|
+
CHECK(priority IN ('critical', 'standard', 'nice-to-have')),
|
|
115
|
+
portal_scope TEXT DEFAULT '[]',
|
|
116
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
117
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
118
|
+
removed_at TEXT,
|
|
119
|
+
removed_reason TEXT
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_domain ON massu_sentinel(domain);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_status ON massu_sentinel(status);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_key ON massu_sentinel(feature_key);
|
|
125
|
+
|
|
126
|
+
-- Feature-to-component mapping (many-to-many)
|
|
127
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel_components (
|
|
128
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
129
|
+
feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
130
|
+
component_file TEXT NOT NULL,
|
|
131
|
+
component_name TEXT,
|
|
132
|
+
role TEXT DEFAULT 'implementation'
|
|
133
|
+
CHECK(role IN ('implementation', 'ui', 'data', 'utility')),
|
|
134
|
+
is_primary BOOLEAN DEFAULT 0,
|
|
135
|
+
UNIQUE(feature_id, component_file, component_name)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
-- Feature-to-procedure mapping (many-to-many)
|
|
139
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel_procedures (
|
|
140
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
141
|
+
feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
142
|
+
router_name TEXT NOT NULL,
|
|
143
|
+
procedure_name TEXT NOT NULL,
|
|
144
|
+
procedure_type TEXT,
|
|
145
|
+
UNIQUE(feature_id, router_name, procedure_name)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
-- Feature-to-page mapping (where feature is accessible)
|
|
149
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel_pages (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
152
|
+
page_route TEXT NOT NULL,
|
|
153
|
+
portal TEXT,
|
|
154
|
+
UNIQUE(feature_id, page_route, portal)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
-- Feature dependency graph
|
|
158
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel_deps (
|
|
159
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
160
|
+
feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
161
|
+
depends_on_feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
162
|
+
dependency_type TEXT DEFAULT 'requires'
|
|
163
|
+
CHECK(dependency_type IN ('requires', 'enhances', 'replaces')),
|
|
164
|
+
UNIQUE(feature_id, depends_on_feature_id)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
-- Feature change log (audit trail)
|
|
168
|
+
CREATE TABLE IF NOT EXISTS massu_sentinel_changelog (
|
|
169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
+
feature_id INTEGER NOT NULL REFERENCES massu_sentinel(id) ON DELETE CASCADE,
|
|
171
|
+
change_type TEXT NOT NULL,
|
|
172
|
+
changed_by TEXT,
|
|
173
|
+
change_detail TEXT,
|
|
174
|
+
commit_hash TEXT,
|
|
175
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_components_file ON massu_sentinel_components(component_file);
|
|
179
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_procedures_router ON massu_sentinel_procedures(router_name);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_pages_route ON massu_sentinel_pages(page_route);
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_sentinel_changelog_feature ON massu_sentinel_changelog(feature_id);
|
|
182
|
+
`);
|
|
183
|
+
|
|
184
|
+
// FTS5 for feature search (separate exec since virtual tables can't be in same batch)
|
|
185
|
+
db.exec(`
|
|
186
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS massu_sentinel_fts USING fts5(
|
|
187
|
+
feature_key, title, description, domain, subdomain,
|
|
188
|
+
content=massu_sentinel, content_rowid=id
|
|
189
|
+
);
|
|
190
|
+
`);
|
|
191
|
+
|
|
192
|
+
// FTS5 sync triggers
|
|
193
|
+
db.exec(`
|
|
194
|
+
CREATE TRIGGER IF NOT EXISTS massu_sentinel_ai AFTER INSERT ON massu_sentinel BEGIN
|
|
195
|
+
INSERT INTO massu_sentinel_fts(rowid, feature_key, title, description, domain, subdomain)
|
|
196
|
+
VALUES (new.id, new.feature_key, new.title, new.description, new.domain, new.subdomain);
|
|
197
|
+
END;
|
|
198
|
+
|
|
199
|
+
CREATE TRIGGER IF NOT EXISTS massu_sentinel_ad AFTER DELETE ON massu_sentinel BEGIN
|
|
200
|
+
INSERT INTO massu_sentinel_fts(massu_sentinel_fts, rowid, feature_key, title, description, domain, subdomain)
|
|
201
|
+
VALUES ('delete', old.id, old.feature_key, old.title, old.description, old.domain, old.subdomain);
|
|
202
|
+
END;
|
|
203
|
+
|
|
204
|
+
CREATE TRIGGER IF NOT EXISTS massu_sentinel_au AFTER UPDATE ON massu_sentinel BEGIN
|
|
205
|
+
INSERT INTO massu_sentinel_fts(massu_sentinel_fts, rowid, feature_key, title, description, domain, subdomain)
|
|
206
|
+
VALUES ('delete', old.id, old.feature_key, old.title, old.description, old.domain, old.subdomain);
|
|
207
|
+
INSERT INTO massu_sentinel_fts(rowid, feature_key, title, description, domain, subdomain)
|
|
208
|
+
VALUES (new.id, new.feature_key, new.title, new.description, new.domain, new.subdomain);
|
|
209
|
+
END;
|
|
210
|
+
`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if Massu indexes are stale compared to CodeGraph timestamps.
|
|
215
|
+
*/
|
|
216
|
+
export function isDataStale(dataDb: Database.Database, codegraphDb: Database.Database): boolean {
|
|
217
|
+
const lastBuild = dataDb.prepare("SELECT value FROM massu_meta WHERE key = 'last_build_time'").get() as { value: string } | undefined;
|
|
218
|
+
if (!lastBuild) return true;
|
|
219
|
+
|
|
220
|
+
// CodeGraph stores indexed_at as unix timestamp (integer)
|
|
221
|
+
const latestIndexed = codegraphDb.prepare("SELECT MAX(indexed_at) as latest FROM files").get() as { latest: number } | undefined;
|
|
222
|
+
if (!latestIndexed?.latest) return true;
|
|
223
|
+
|
|
224
|
+
// Convert CodeGraph's unix timestamp to ms and compare with our ISO date
|
|
225
|
+
return (latestIndexed.latest * 1000) > new Date(lastBuild.value).getTime();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Update the last build timestamp in massu_meta.
|
|
230
|
+
*/
|
|
231
|
+
export function updateBuildTimestamp(dataDb: Database.Database): void {
|
|
232
|
+
dataDb.prepare("INSERT OR REPLACE INTO massu_meta (key, value) VALUES ('last_build_time', ?)").run(new Date().toISOString());
|
|
233
|
+
}
|