@selvakumaresra/specship 0.4.0 → 0.5.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 +11 -1
- package/commands/ss-brainstorm.md +68 -0
- package/dist/analytics/specship-impact.d.ts +72 -0
- package/dist/analytics/specship-impact.d.ts.map +1 -0
- package/dist/analytics/specship-impact.js +216 -0
- package/dist/analytics/specship-impact.js.map +1 -0
- package/dist/bin/specship.js +4 -4
- package/dist/bin/specship.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +15 -1
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/schema.sql +8 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/index.d.ts +2 -2
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +2 -0
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/server/ingest/impact-backfill.js +69 -0
- package/dist/server/ingest/impact-query.js +343 -0
- package/dist/server/ingest/index.js +2 -1
- package/dist/server/ingest/ingestor.js +41 -6
- package/dist/server/ingest/specship-classify.js +153 -0
- package/dist/server/routes/claude.js +32 -0
- package/dist/server/routes/spec.js +94 -0
- package/dist/server/server.js +26 -2
- package/dist/web/chunk-O7434ZMN.js +1 -0
- package/dist/web/{chunk-2YUJNZ2Y.js → chunk-ODX6CT3I.js} +6 -6
- package/dist/web/chunk-RASJHUXS.js +1 -0
- package/dist/web/chunk-TQ3P2QZO.js +1 -0
- package/dist/web/chunk-WCHGDXWC.js +1 -0
- package/dist/web/index.html +1 -1
- package/dist/web/main-X2KCYXZ4.js +1 -0
- package/package.json +1 -1
- package/dist/web/chunk-B3YPFY6A.js +0 -1
- package/dist/web/chunk-GWPVKJIY.js +0 -1
- package/dist/web/main-R53HA54V.js +0 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computeSpecshipImpact — aggregate SpecShip token-impact metrics from
|
|
3
|
+
* claude_tool_calls + claude_sessions.
|
|
4
|
+
*
|
|
5
|
+
* Pure function of (db, opts) — no Fastify dependency, unit-testable.
|
|
6
|
+
* Called by GET /api/claude/specship-impact in routes/claude.ts.
|
|
7
|
+
*
|
|
8
|
+
* Algorithm (SQL fetch + JS reduction — no GROUP BY prompt_id/file SQL):
|
|
9
|
+
* 1. Fetch scoped rows joining claude_tool_calls ↔ claude_sessions.
|
|
10
|
+
* 2. Spend: ceil(Σ result_length / CHARS_PER_TOKEN) for is_specship=1.
|
|
11
|
+
* 3. Saved: per-prompt dedup — union displaced file paths across a prompt's
|
|
12
|
+
* resolved calls, sum each distinct file's size ONCE, subtract the
|
|
13
|
+
* prompt's specship spend chars, floor at 0.
|
|
14
|
+
* 4. Overhead: ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN) ×
|
|
15
|
+
* distinct session count with ≥1 specship call.
|
|
16
|
+
* 5. Cost: price spend/saved chars at input-token rate, grouped by model.
|
|
17
|
+
*/
|
|
18
|
+
import { normalizeModelId, resolvePricing } from './pricing.js';
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constants
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Characters treated as one token (rough average for Claude). */
|
|
23
|
+
const CHARS_PER_TOKEN = 4;
|
|
24
|
+
/**
|
|
25
|
+
* JSON.stringify length of the SpecShip MCP tool definitions array.
|
|
26
|
+
* Measured: JSON.stringify(tools.filter(t => t.name.startsWith('specship_'))).length
|
|
27
|
+
* at 2026-06-24 — 12 tools in the registry at that time.
|
|
28
|
+
* See src/mcp/tools.ts; recheck after adding/removing tools.
|
|
29
|
+
*/
|
|
30
|
+
const SPECSHIP_TOOLDEF_CHARS = 10047;
|
|
31
|
+
/** Overhead tokens charged per session (tool-definition context cost). */
|
|
32
|
+
const OVERHEAD_TOKENS_PER_SESSION = Math.ceil(SPECSHIP_TOOLDEF_CHARS / CHARS_PER_TOKEN);
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function parseDisplacedFiles(raw) {
|
|
37
|
+
if (!raw)
|
|
38
|
+
return [];
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (!Array.isArray(parsed))
|
|
42
|
+
return [];
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Load pricing rows from the DB (returns [] if the table is empty or absent).
|
|
51
|
+
*/
|
|
52
|
+
function loadPricing(db) {
|
|
53
|
+
try {
|
|
54
|
+
return db
|
|
55
|
+
.prepare('SELECT model, input_per_mtok, output_per_mtok, cache_creation_per_mtok, cache_read_per_mtok FROM claude_pricing')
|
|
56
|
+
.all();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Price a char count (already summed) as input tokens at the input rate for
|
|
64
|
+
* the given model. Returns 0 when model is unknown or pricing is unavailable.
|
|
65
|
+
*/
|
|
66
|
+
function priceChars(chars, model, pricingRows) {
|
|
67
|
+
if (!model || pricingRows.length === 0)
|
|
68
|
+
return 0;
|
|
69
|
+
const pricing = resolvePricing(model, pricingRows);
|
|
70
|
+
if (!pricing)
|
|
71
|
+
return 0;
|
|
72
|
+
const tokens = chars / CHARS_PER_TOKEN;
|
|
73
|
+
return (tokens * pricing.input_per_mtok) / 1_000_000;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Per-prompt dedup logic. Given the rows for ONE prompt:
|
|
77
|
+
* - union displaced file paths from resolved rows (same file counted once)
|
|
78
|
+
* - promptSavedChars = max(0, unionSize - promptSpendChars)
|
|
79
|
+
*
|
|
80
|
+
* Returns { savedChars, spendChars }.
|
|
81
|
+
*/
|
|
82
|
+
function computePromptSaved(rows) {
|
|
83
|
+
const spendChars = rows
|
|
84
|
+
.filter(r => r.is_specship === 1)
|
|
85
|
+
.reduce((s, r) => s + (r.result_length ?? 0), 0);
|
|
86
|
+
// Union displaced files across ALL resolved calls in this prompt.
|
|
87
|
+
const fileMap = new Map(); // path → size
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (row.is_specship !== 1 || row.resolution !== 'resolved')
|
|
90
|
+
continue;
|
|
91
|
+
for (const [path, size] of parseDisplacedFiles(row.displaced_files)) {
|
|
92
|
+
if (!fileMap.has(path)) {
|
|
93
|
+
fileMap.set(path, size);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const readEquivChars = Array.from(fileMap.values()).reduce((s, n) => s + n, 0);
|
|
98
|
+
const savedChars = Math.max(0, readEquivChars - spendChars);
|
|
99
|
+
return { savedChars, spendChars };
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Main export
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
export function computeSpecshipImpact(db, opts) {
|
|
105
|
+
const { since, project, sessionId } = opts;
|
|
106
|
+
// 1. Fetch scoped rows.
|
|
107
|
+
const queryParts = [
|
|
108
|
+
`SELECT tc.prompt_id, tc.tool_name, tc.is_specship, tc.result_length,
|
|
109
|
+
tc.displaced_files, tc.resolution, tc.ts,
|
|
110
|
+
s.project_path, s.last_model
|
|
111
|
+
FROM claude_tool_calls tc
|
|
112
|
+
JOIN claude_sessions s ON s.id = tc.session_id
|
|
113
|
+
WHERE tc.ts >= ?`,
|
|
114
|
+
];
|
|
115
|
+
const params = [since];
|
|
116
|
+
if (project) {
|
|
117
|
+
queryParts.push('AND s.project_path = ?');
|
|
118
|
+
params.push(project);
|
|
119
|
+
}
|
|
120
|
+
if (sessionId) {
|
|
121
|
+
queryParts.push('AND tc.session_id = ?');
|
|
122
|
+
params.push(sessionId);
|
|
123
|
+
}
|
|
124
|
+
const rows = db.prepare(queryParts.join(' ')).all(...params);
|
|
125
|
+
if (rows.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
spendTokens: 0,
|
|
128
|
+
spendCostUsd: 0,
|
|
129
|
+
savedTokens: 0,
|
|
130
|
+
savedCostUsd: 0,
|
|
131
|
+
overheadTokens: 0,
|
|
132
|
+
netTokens: 0,
|
|
133
|
+
netCostUsd: 0,
|
|
134
|
+
unresolvedCalls: 0,
|
|
135
|
+
totalSpecshipCalls: 0,
|
|
136
|
+
byTool: [],
|
|
137
|
+
byProject: [],
|
|
138
|
+
trend: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const pricingRows = loadPricing(db);
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// 2. Group rows by prompt_id for per-prompt dedup.
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const byPrompt = new Map();
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
const key = row.prompt_id ?? `__no_prompt_${row.ts}`;
|
|
148
|
+
const arr = byPrompt.get(key);
|
|
149
|
+
if (arr)
|
|
150
|
+
arr.push(row);
|
|
151
|
+
else
|
|
152
|
+
byPrompt.set(key, [row]);
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// 3. Global spend / saved (with per-prompt dedup) + cost by model.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
let totalSpendChars = 0;
|
|
158
|
+
let totalSavedChars = 0;
|
|
159
|
+
let totalSpecshipCalls = 0;
|
|
160
|
+
let unresolvedCalls = 0;
|
|
161
|
+
// For cost we need to track chars by model.
|
|
162
|
+
const spendCharsByModel = new Map();
|
|
163
|
+
const savedCharsByModel = new Map();
|
|
164
|
+
// Per-tool accumulators: spend is exact; saved is a per-tool APPROXIMATION.
|
|
165
|
+
// Exact per-tool saved attribution is ambiguous when two tools in the same
|
|
166
|
+
// prompt share a file. We approximate: a tool's saved chars = its own
|
|
167
|
+
// resolved displaced files (deduped within the tool, not globally within
|
|
168
|
+
// the prompt). This overestimates when tools share files but keeps
|
|
169
|
+
// individual tool savings intuitive. Spend is always exact.
|
|
170
|
+
const toolSpendChars = new Map();
|
|
171
|
+
const toolSavedChars = new Map();
|
|
172
|
+
const toolCalls = new Map();
|
|
173
|
+
// Per-project (only when no project filter).
|
|
174
|
+
const projectSpendChars = new Map();
|
|
175
|
+
const projectSavedChars = new Map();
|
|
176
|
+
// Trend: daily buckets.
|
|
177
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
178
|
+
const trendSpend = new Map(); // dayBucket → chars
|
|
179
|
+
const trendSaved = new Map();
|
|
180
|
+
// Pass 1: global spend/saved via per-prompt dedup.
|
|
181
|
+
for (const [, promptRows] of byPrompt) {
|
|
182
|
+
const { savedChars, spendChars } = computePromptSaved(promptRows);
|
|
183
|
+
totalSpendChars += spendChars;
|
|
184
|
+
totalSavedChars += savedChars;
|
|
185
|
+
// Attribute spend/saved chars to the session's model.
|
|
186
|
+
// All rows in a prompt share the same session (same last_model).
|
|
187
|
+
const model = promptRows[0]?.last_model ?? null;
|
|
188
|
+
const normModel = model ? normalizeModelId(model) : null;
|
|
189
|
+
if (normModel) {
|
|
190
|
+
spendCharsByModel.set(normModel, (spendCharsByModel.get(normModel) ?? 0) + spendChars);
|
|
191
|
+
savedCharsByModel.set(normModel, (savedCharsByModel.get(normModel) ?? 0) + savedChars);
|
|
192
|
+
}
|
|
193
|
+
// Track per-day trend for this prompt's is_specship rows.
|
|
194
|
+
for (const row of promptRows) {
|
|
195
|
+
if (row.is_specship !== 1)
|
|
196
|
+
continue;
|
|
197
|
+
totalSpecshipCalls++;
|
|
198
|
+
if (row.resolution === 'unresolved')
|
|
199
|
+
unresolvedCalls++;
|
|
200
|
+
// Session tracking (for overhead).
|
|
201
|
+
// We need the session_id — it's not in our fetch. Derive from project+model
|
|
202
|
+
// won't work. We'll collect it via a separate map after this loop using rows.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// We need session_ids for overhead. Since we joined sessions but didn't select
|
|
206
|
+
// session_id, do a lightweight distinct-session count via a separate query.
|
|
207
|
+
const sessionCountQParts = [
|
|
208
|
+
`SELECT COUNT(DISTINCT tc.session_id) as cnt
|
|
209
|
+
FROM claude_tool_calls tc
|
|
210
|
+
JOIN claude_sessions s ON s.id = tc.session_id
|
|
211
|
+
WHERE tc.ts >= ? AND tc.is_specship = 1`,
|
|
212
|
+
];
|
|
213
|
+
const sessionCountParams = [since];
|
|
214
|
+
if (project) {
|
|
215
|
+
sessionCountQParts.push('AND s.project_path = ?');
|
|
216
|
+
sessionCountParams.push(project);
|
|
217
|
+
}
|
|
218
|
+
if (sessionId) {
|
|
219
|
+
sessionCountQParts.push('AND tc.session_id = ?');
|
|
220
|
+
sessionCountParams.push(sessionId);
|
|
221
|
+
}
|
|
222
|
+
const sessionCountRow = db
|
|
223
|
+
.prepare(sessionCountQParts.join(' '))
|
|
224
|
+
.get(...sessionCountParams);
|
|
225
|
+
const specshipSessionCount = sessionCountRow?.cnt ?? 0;
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Pass 2: per-tool and trend (row-level, NOT requiring per-prompt dedup).
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Per-tool saved approximation: for each is_specship row with resolution=resolved,
|
|
230
|
+
// compute its own per-call "saved" as max(0, sum(displaced_file_sizes) - result_length).
|
|
231
|
+
// This is approximate because it ignores cross-call file sharing within a prompt,
|
|
232
|
+
// but it gives an intuitive per-tool estimate and spend is always exact.
|
|
233
|
+
for (const row of rows) {
|
|
234
|
+
const dayBucket = Math.floor(row.ts / dayMs) * dayMs;
|
|
235
|
+
if (row.is_specship !== 1)
|
|
236
|
+
continue;
|
|
237
|
+
const toolName = row.tool_name ?? 'unknown';
|
|
238
|
+
toolCalls.set(toolName, (toolCalls.get(toolName) ?? 0) + 1);
|
|
239
|
+
const spend = row.result_length ?? 0;
|
|
240
|
+
toolSpendChars.set(toolName, (toolSpendChars.get(toolName) ?? 0) + spend);
|
|
241
|
+
// Per-tool saved approximation: own displaced files minus own spend, ≥0.
|
|
242
|
+
const displaced = parseDisplacedFiles(row.displaced_files);
|
|
243
|
+
if (row.resolution === 'resolved' && displaced.length > 0) {
|
|
244
|
+
const fileUnion = new Map();
|
|
245
|
+
for (const [p, sz] of displaced)
|
|
246
|
+
if (!fileUnion.has(p))
|
|
247
|
+
fileUnion.set(p, sz);
|
|
248
|
+
const readEquiv = Array.from(fileUnion.values()).reduce((s, n) => s + n, 0);
|
|
249
|
+
const toolSaved = Math.max(0, readEquiv - spend);
|
|
250
|
+
toolSavedChars.set(toolName, (toolSavedChars.get(toolName) ?? 0) + toolSaved);
|
|
251
|
+
}
|
|
252
|
+
// Trend.
|
|
253
|
+
trendSpend.set(dayBucket, (trendSpend.get(dayBucket) ?? 0) + spend);
|
|
254
|
+
}
|
|
255
|
+
// Per-prompt saved for trend and byProject (both need per-prompt dedup).
|
|
256
|
+
for (const [, promptRows] of byPrompt) {
|
|
257
|
+
const { savedChars } = computePromptSaved(promptRows);
|
|
258
|
+
const project_path = promptRows[0]?.project_path ?? '';
|
|
259
|
+
const dayBucket = Math.floor((promptRows[0]?.ts ?? 0) / dayMs) * dayMs;
|
|
260
|
+
// Trend saved.
|
|
261
|
+
if (savedChars > 0) {
|
|
262
|
+
trendSaved.set(dayBucket, (trendSaved.get(dayBucket) ?? 0) + savedChars);
|
|
263
|
+
}
|
|
264
|
+
// byProject accumulators.
|
|
265
|
+
if (!project) {
|
|
266
|
+
const spendChars = promptRows
|
|
267
|
+
.filter(r => r.is_specship === 1)
|
|
268
|
+
.reduce((s, r) => s + (r.result_length ?? 0), 0);
|
|
269
|
+
projectSpendChars.set(project_path, (projectSpendChars.get(project_path) ?? 0) + spendChars);
|
|
270
|
+
projectSavedChars.set(project_path, (projectSavedChars.get(project_path) ?? 0) + savedChars);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// 4. Compute tokens + cost.
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
const spendTokens = Math.ceil(totalSpendChars / CHARS_PER_TOKEN);
|
|
277
|
+
const savedTokens = Math.ceil(totalSavedChars / CHARS_PER_TOKEN);
|
|
278
|
+
const overheadTokens = OVERHEAD_TOKENS_PER_SESSION * specshipSessionCount;
|
|
279
|
+
let spendCostUsd = 0;
|
|
280
|
+
let savedCostUsd = 0;
|
|
281
|
+
for (const [model, chars] of spendCharsByModel) {
|
|
282
|
+
spendCostUsd += priceChars(chars, model, pricingRows);
|
|
283
|
+
}
|
|
284
|
+
for (const [model, chars] of savedCharsByModel) {
|
|
285
|
+
savedCostUsd += priceChars(chars, model, pricingRows);
|
|
286
|
+
}
|
|
287
|
+
const netTokens = savedTokens - spendTokens - overheadTokens;
|
|
288
|
+
const netCostUsd = savedCostUsd - spendCostUsd;
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 5. byTool.
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
const byTool = Array.from(toolCalls.entries()).map(([tool, calls]) => ({
|
|
293
|
+
tool,
|
|
294
|
+
calls,
|
|
295
|
+
spendTokens: Math.ceil((toolSpendChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
|
|
296
|
+
savedTokens: Math.ceil((toolSavedChars.get(tool) ?? 0) / CHARS_PER_TOKEN),
|
|
297
|
+
})).sort((a, b) => b.calls - a.calls);
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// 6. byProject (only when no project filter).
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// NOTE: per-project netTokens = saved − spend and intentionally does NOT
|
|
302
|
+
// subtract the tool-definition overhead. Overhead is a per-session constant
|
|
303
|
+
// and isn't attributed to individual projects, so the byProject rows will
|
|
304
|
+
// not sum to the headline netTokens (which does include overhead). The page
|
|
305
|
+
// methodology note discloses this.
|
|
306
|
+
const byProject = project
|
|
307
|
+
? []
|
|
308
|
+
: Array.from(projectSpendChars.keys()).map((proj) => {
|
|
309
|
+
const spend = Math.ceil((projectSpendChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
|
|
310
|
+
const saved = Math.ceil((projectSavedChars.get(proj) ?? 0) / CHARS_PER_TOKEN);
|
|
311
|
+
return {
|
|
312
|
+
project: proj,
|
|
313
|
+
spendTokens: spend,
|
|
314
|
+
savedTokens: saved,
|
|
315
|
+
netTokens: saved - spend, // overhead not allocated per-project — see note above
|
|
316
|
+
};
|
|
317
|
+
}).sort((a, b) => b.spendTokens - a.spendTokens);
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// 7. trend.
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
const allDayBuckets = new Set([...trendSpend.keys(), ...trendSaved.keys()]);
|
|
322
|
+
const trend = Array.from(allDayBuckets)
|
|
323
|
+
.sort((a, b) => a - b)
|
|
324
|
+
.map((ts) => ({
|
|
325
|
+
ts,
|
|
326
|
+
spendTokens: Math.ceil((trendSpend.get(ts) ?? 0) / CHARS_PER_TOKEN),
|
|
327
|
+
savedTokens: Math.ceil((trendSaved.get(ts) ?? 0) / CHARS_PER_TOKEN),
|
|
328
|
+
}));
|
|
329
|
+
return {
|
|
330
|
+
spendTokens,
|
|
331
|
+
spendCostUsd,
|
|
332
|
+
savedTokens,
|
|
333
|
+
savedCostUsd,
|
|
334
|
+
overheadTokens,
|
|
335
|
+
netTokens,
|
|
336
|
+
netCostUsd,
|
|
337
|
+
unresolvedCalls,
|
|
338
|
+
totalSpecshipCalls,
|
|
339
|
+
byTool,
|
|
340
|
+
byProject,
|
|
341
|
+
trend,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* - `ingestAll(db)` — one-shot pass, useful for CI / scripts.
|
|
13
13
|
* - `startWatcher(db)` — live tail; runs ingestAll once + on each FS event.
|
|
14
14
|
*/
|
|
15
|
-
export { ingestAll, listTranscriptFiles, decodeProjectSlug } from './ingestor.js';
|
|
15
|
+
export { ingestAll, listTranscriptFiles, decodeProjectSlug, primaryProjectMatcher } from './ingestor.js';
|
|
16
16
|
export { startWatcher } from './watcher.js';
|
|
17
17
|
export { resolvePricing, computeCost, normalizeModelId } from './pricing.js';
|
|
18
18
|
export { parseLine, summarizeToolInput, extractUserPrompt, toolResultLength } from './parser.js';
|
|
19
|
+
export { backfillDisplaced } from './impact-backfill.js';
|
|
@@ -27,6 +27,7 @@ import * as os from 'os';
|
|
|
27
27
|
import * as path from 'path';
|
|
28
28
|
import { computeCost, resolvePricing } from './pricing.js';
|
|
29
29
|
import { parseLine, toEpochMs, extractUserPrompt, summarizeToolInput, toolResultLength, } from './parser.js';
|
|
30
|
+
import { classifyToolCall } from './specship-classify.js';
|
|
30
31
|
/**
|
|
31
32
|
* Convert Claude's slash-escaped project dir name back into a real path.
|
|
32
33
|
* `~/.claude/projects/-Users-alice-projects-foo` → `/Users/alice/projects/foo`
|
|
@@ -34,6 +35,24 @@ import { parseLine, toEpochMs, extractUserPrompt, summarizeToolInput, toolResult
|
|
|
34
35
|
export function decodeProjectSlug(slug) {
|
|
35
36
|
return slug.startsWith('-') ? '/' + slug.slice(1).replace(/-/g, '/') : slug;
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a predicate that tells whether a stored `project_path` belongs to the
|
|
40
|
+
* primary project, given the primary's REAL filesystem path.
|
|
41
|
+
*
|
|
42
|
+
* Why this isn't a plain `===`: `decodeProjectSlug` lossily turns every '-' in
|
|
43
|
+
* the slug into '/', so a real path like `/Users/a/dev/claude-projects/x` gets
|
|
44
|
+
* STORED as `/Users/a/dev/claude/projects/x`. The savings graph resolver only
|
|
45
|
+
* has the real primary path, so it never matched the mangled stored form and
|
|
46
|
+
* every call resolved to a null graph (savedTokens stuck at 0). We match BOTH
|
|
47
|
+
* the real path and its mangled form (computed by round-tripping the real path
|
|
48
|
+
* through the same encode→decode the storage used). No stored data is changed,
|
|
49
|
+
* so the (consistently-mangled) project filter is unaffected.
|
|
50
|
+
*/
|
|
51
|
+
export function primaryProjectMatcher(primaryRealPath) {
|
|
52
|
+
// encode: real path → slug form (every '/' → '-'); decode: slug → mangled path.
|
|
53
|
+
const mangled = decodeProjectSlug(primaryRealPath.replace(/\//g, '-'));
|
|
54
|
+
return (storedPath) => storedPath === primaryRealPath || storedPath === mangled;
|
|
55
|
+
}
|
|
37
56
|
/**
|
|
38
57
|
* List every JSONL file inside `<claudeRoot>/<slug>/<sessionId>.jsonl`.
|
|
39
58
|
* Returns the absolute file path + the decoded project path.
|
|
@@ -222,9 +241,22 @@ function ingestFile(db, filePath, projectPath, pricing, options) {
|
|
|
222
241
|
VALUES (?, ?, ?, ?)
|
|
223
242
|
ON CONFLICT(path) DO UPDATE SET last_seen = excluded.last_seen
|
|
224
243
|
`).run(projectPath, projectName, now, now);
|
|
244
|
+
// Resolve the graph once per file (per project path) — avoids reopening the DB
|
|
245
|
+
// on every tool call. Returns null when no resolver is configured. The
|
|
246
|
+
// resolver can throw on a locked/corrupt index; that estimate is best-effort
|
|
247
|
+
// decoration and must NEVER abort transcript ingest, so degrade to null.
|
|
248
|
+
let graph = null;
|
|
249
|
+
if (options.resolveGraph) {
|
|
250
|
+
try {
|
|
251
|
+
graph = options.resolveGraph(projectPath);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
graph = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
225
257
|
// Per-file ingest in a transaction.
|
|
226
258
|
const txn = db.transaction(() => {
|
|
227
|
-
return processLines(db, filePath, projectPath, completeLines, pricing);
|
|
259
|
+
return processLines(db, filePath, projectPath, completeLines, pricing, graph);
|
|
228
260
|
});
|
|
229
261
|
const result = txn();
|
|
230
262
|
// Persist new offset.
|
|
@@ -255,7 +287,7 @@ function ingestFile(db, filePath, projectPath, pricing, options) {
|
|
|
255
287
|
* the next user entry) so result_length is captured. Maintain a map of
|
|
256
288
|
* pending tool_use_id → { promptId, name, summary, ts } as we walk.
|
|
257
289
|
*/
|
|
258
|
-
function processLines(db, filePath, projectPath, completeLines, pricing) {
|
|
290
|
+
function processLines(db, filePath, projectPath, completeLines, pricing, graph) {
|
|
259
291
|
const insSession = db.prepare(`
|
|
260
292
|
INSERT INTO claude_sessions (id, project_path, source_file, started_at, ended_at, prompt_count, last_model)
|
|
261
293
|
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
@@ -286,8 +318,8 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
|
|
|
286
318
|
cost_usd = excluded.cost_usd
|
|
287
319
|
`);
|
|
288
320
|
const insToolCall = db.prepare(`
|
|
289
|
-
INSERT INTO claude_tool_calls (prompt_id, session_id, assistant_uuid, tool_use_id, tool_name, input_summary, input_json, result_length, ts)
|
|
290
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
321
|
+
INSERT INTO claude_tool_calls (prompt_id, session_id, assistant_uuid, tool_use_id, tool_name, input_summary, input_json, result_length, ts, is_specship, displaced_files, resolution)
|
|
322
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
291
323
|
`);
|
|
292
324
|
/**
|
|
293
325
|
* Append the assistant's text + thinking blocks from one assistant turn
|
|
@@ -387,7 +419,8 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
|
|
|
387
419
|
const len = toolResultLength(block);
|
|
388
420
|
const pending = pendingTools.get(block.tool_use_id);
|
|
389
421
|
if (pending) {
|
|
390
|
-
|
|
422
|
+
const cls = classifyToolCall({ toolName: pending.toolName, inputJson: pending.inputJson, resultLength: len }, graph);
|
|
423
|
+
insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, block.tool_use_id, pending.toolName, pending.summary, pending.inputJson, len, pending.ts, cls.isSpecship, cls.displacedFiles, cls.resolution);
|
|
391
424
|
toolCallsInserted++;
|
|
392
425
|
pendingTools.delete(block.tool_use_id);
|
|
393
426
|
}
|
|
@@ -492,7 +525,9 @@ function processLines(db, filePath, projectPath, completeLines, pricing) {
|
|
|
492
525
|
// result_length=0 so the tool call still shows up in analytics (better to
|
|
493
526
|
// show "0 tokens returned" than to omit the call).
|
|
494
527
|
for (const [toolUseId, pending] of pendingTools) {
|
|
495
|
-
|
|
528
|
+
// result_length=0 for pending/unmatched tools → classifyToolCall returns 'n/a' for specship.
|
|
529
|
+
const cls = classifyToolCall({ toolName: pending.toolName, inputJson: pending.inputJson, resultLength: 0 }, graph);
|
|
530
|
+
insToolCall.run(pending.promptId, pending.sessionId, pending.assistantUuid, toolUseId, pending.toolName, pending.summary, pending.inputJson, 0, pending.ts, cls.isSpecship, cls.displacedFiles, cls.resolution);
|
|
496
531
|
toolCallsInserted++;
|
|
497
532
|
}
|
|
498
533
|
return {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classifiers and symbol-extraction helpers for SpecShip Token Impact.
|
|
3
|
+
*
|
|
4
|
+
* SERVER-LOCAL COPY. The lib has the canonical version at
|
|
5
|
+
* `src/analytics/specship-impact.ts`; this file is duplicated here on purpose
|
|
6
|
+
* so the server never carries a runtime `import … from '@selvakumaresra/specship'`
|
|
7
|
+
* — a bare-package value import does NOT resolve in bundled / different-cwd
|
|
8
|
+
* mode and silently drops the server back to a stale build (the same failure
|
|
9
|
+
* mode `server.ts`/`workflow.ts` already guard against). These functions are
|
|
10
|
+
* pure (no I/O, no deps), so duplication is cheap; `__tests__/specship-impact-parity.test.ts`
|
|
11
|
+
* asserts the two copies stay behaviourally identical.
|
|
12
|
+
*/
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Constants
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/** Prefix shared by every MCP tool routed through the specship server. */
|
|
17
|
+
const MCP_SPECSHIP_PREFIX = 'mcp__specship__';
|
|
18
|
+
/**
|
|
19
|
+
* Strip-prefix base names of SpecShip tools that return code-graph source.
|
|
20
|
+
* These are the tools a SpecShip call can displace a native Read/Grep with.
|
|
21
|
+
* Excluded: designer_*, specship_link_assert, specship_link_verify,
|
|
22
|
+
* specship_spec, specship_drifted, specship_status — they don't return
|
|
23
|
+
* indexed source symbols.
|
|
24
|
+
*/
|
|
25
|
+
const SOURCE_RETURNING_TOOLS = new Set([
|
|
26
|
+
'specship_explore',
|
|
27
|
+
'specship_node',
|
|
28
|
+
'specship_callers',
|
|
29
|
+
'specship_callees',
|
|
30
|
+
'specship_impact',
|
|
31
|
+
'specship_search',
|
|
32
|
+
'specship_files',
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* Tools whose input carries a `symbol` key (single identifier string).
|
|
36
|
+
* Verified against src/mcp/tools.ts inputSchema `required: ['symbol']`.
|
|
37
|
+
*/
|
|
38
|
+
const SYMBOL_KEY_TOOLS = new Set([
|
|
39
|
+
'specship_node',
|
|
40
|
+
'specship_callers',
|
|
41
|
+
'specship_callees',
|
|
42
|
+
'specship_impact',
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* Regex for a single valid identifier token (with optional Class.method
|
|
46
|
+
* qualifier).
|
|
47
|
+
*/
|
|
48
|
+
const SYMBOL_TOKEN_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)?$/;
|
|
49
|
+
/**
|
|
50
|
+
* Regex a token must match to be considered "code-ish" rather than prose.
|
|
51
|
+
* Real symbol names almost always contain an uppercase letter, underscore,
|
|
52
|
+
* dollar sign, digit, or a dot; pure lowercase words look like prose.
|
|
53
|
+
*/
|
|
54
|
+
const CODE_SIGNAL_RE = /[A-Z_$\d.]/;
|
|
55
|
+
/** Cap on how many symbol-shaped tokens we pull from one query. */
|
|
56
|
+
const MAX_SYMBOLS_PER_QUERY = 16;
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Public API
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/** True iff `name` is routed through the SpecShip MCP server. */
|
|
61
|
+
export function isSpecshipTool(name) {
|
|
62
|
+
return name.startsWith(MCP_SPECSHIP_PREFIX);
|
|
63
|
+
}
|
|
64
|
+
/** True iff the tool returns code-graph source that can displace a Read/Grep. */
|
|
65
|
+
export function isSourceReturningTool(name) {
|
|
66
|
+
if (!name.startsWith(MCP_SPECSHIP_PREFIX))
|
|
67
|
+
return false;
|
|
68
|
+
const base = name.slice(MCP_SPECSHIP_PREFIX.length);
|
|
69
|
+
return SOURCE_RETURNING_TOOLS.has(base);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Extracts the symbol names a tool call asked about from its serialised input
|
|
73
|
+
* JSON. Returns `[]` when no symbols are resolvable (bad JSON, prose query,
|
|
74
|
+
* non-symbol tool).
|
|
75
|
+
*/
|
|
76
|
+
export function extractRequestedSymbols(toolName, inputJson) {
|
|
77
|
+
if (!inputJson)
|
|
78
|
+
return [];
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(inputJson);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
87
|
+
return [];
|
|
88
|
+
const args = parsed;
|
|
89
|
+
if (!toolName.startsWith(MCP_SPECSHIP_PREFIX))
|
|
90
|
+
return [];
|
|
91
|
+
const base = toolName.slice(MCP_SPECSHIP_PREFIX.length);
|
|
92
|
+
if (SYMBOL_KEY_TOOLS.has(base)) {
|
|
93
|
+
const sym = args['symbol'];
|
|
94
|
+
return typeof sym === 'string' && sym.length > 0 ? [sym] : [];
|
|
95
|
+
}
|
|
96
|
+
if (base === 'specship_explore' || base === 'specship_search') {
|
|
97
|
+
const q = args['query'];
|
|
98
|
+
if (typeof q !== 'string' || q.length === 0)
|
|
99
|
+
return [];
|
|
100
|
+
return symbolBagFromQuery(q);
|
|
101
|
+
}
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Classify one tool call → the three ingest columns
|
|
106
|
+
* (`isSpecship`, `resolution`, `displacedFiles`). A graph-estimate failure
|
|
107
|
+
* degrades to 'unresolved' (it must NEVER break core transcript ingest).
|
|
108
|
+
*/
|
|
109
|
+
export function classifyToolCall(call, graph) {
|
|
110
|
+
const { toolName, inputJson, resultLength } = call;
|
|
111
|
+
if (!isSpecshipTool(toolName)) {
|
|
112
|
+
return { isSpecship: 0, resolution: null, displacedFiles: null };
|
|
113
|
+
}
|
|
114
|
+
if (isSourceReturningTool(toolName) && resultLength > 0) {
|
|
115
|
+
const symbols = extractRequestedSymbols(toolName, inputJson);
|
|
116
|
+
if (symbols.length === 0) {
|
|
117
|
+
return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
|
|
118
|
+
}
|
|
119
|
+
if (graph === null) {
|
|
120
|
+
return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
|
|
121
|
+
}
|
|
122
|
+
let files;
|
|
123
|
+
let resolved;
|
|
124
|
+
try {
|
|
125
|
+
({ files, resolved } = graph.estimateReadEquivalent(symbols));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
|
|
129
|
+
}
|
|
130
|
+
if (resolved) {
|
|
131
|
+
return {
|
|
132
|
+
isSpecship: 1,
|
|
133
|
+
resolution: 'resolved',
|
|
134
|
+
displacedFiles: JSON.stringify(files.map((f) => [f.path, f.size])),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { isSpecship: 1, resolution: 'unresolved', displacedFiles: null };
|
|
138
|
+
}
|
|
139
|
+
return { isSpecship: 1, resolution: 'n/a', displacedFiles: null };
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Internal helpers
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Real explore/search queries are MIXED bags (symbol names + lowercase
|
|
145
|
+
// keywords, often 10+ tokens). FILTER the symbol-shaped tokens out rather than
|
|
146
|
+
// rejecting the whole query; pure prose yields []. Capped to bound graph work.
|
|
147
|
+
function symbolBagFromQuery(query) {
|
|
148
|
+
const symbols = query
|
|
149
|
+
.trim()
|
|
150
|
+
.split(/\s+/)
|
|
151
|
+
.filter((t) => SYMBOL_TOKEN_RE.test(t) && CODE_SIGNAL_RE.test(t));
|
|
152
|
+
return symbols.slice(0, MAX_SYMBOLS_PER_QUERY);
|
|
153
|
+
}
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* GET /api/claude/costs?range= — cost rollup, timeseries, per-model
|
|
14
14
|
* GET /api/claude/compare — per-project cost comparison
|
|
15
15
|
* GET /api/claude/tips — rule-based tips engine output
|
|
16
|
+
* GET /api/claude/specship-impact?range=&project= — SpecShip token-impact aggregation
|
|
16
17
|
* POST /api/claude/ingest — force a one-shot ingest pass
|
|
17
18
|
*/
|
|
18
19
|
import { decodeProjectSlug } from '../ingest/index.js';
|
|
20
|
+
import { computeSpecshipImpact } from '../ingest/impact-query.js';
|
|
19
21
|
/**
|
|
20
22
|
* Normalize a `?project=` filter value to the form stored in
|
|
21
23
|
* claude_sessions.project_path. The Sessions page (and any other UI
|
|
@@ -346,6 +348,13 @@ export async function registerClaudeRoutes(app) {
|
|
|
346
348
|
const durationMs = session.started_at && session.ended_at
|
|
347
349
|
? Math.max(0, session.ended_at - session.started_at)
|
|
348
350
|
: 0;
|
|
351
|
+
// SpecShip token-impact rollup for this session.
|
|
352
|
+
const impact = computeSpecshipImpact(db, { since: 0, sessionId });
|
|
353
|
+
const specship = {
|
|
354
|
+
spendTokens: impact.spendTokens,
|
|
355
|
+
savedTokens: impact.savedTokens,
|
|
356
|
+
netTokens: impact.netTokens,
|
|
357
|
+
};
|
|
349
358
|
return {
|
|
350
359
|
sessionId,
|
|
351
360
|
byTool,
|
|
@@ -354,6 +363,7 @@ export async function registerClaudeRoutes(app) {
|
|
|
354
363
|
skills,
|
|
355
364
|
filesTouched,
|
|
356
365
|
durationMs,
|
|
366
|
+
specship,
|
|
357
367
|
};
|
|
358
368
|
});
|
|
359
369
|
app.get('/api/claude/heatmap', async (req, reply) => {
|
|
@@ -855,6 +865,28 @@ export async function registerClaudeRoutes(app) {
|
|
|
855
865
|
tips.sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9));
|
|
856
866
|
return { tips };
|
|
857
867
|
});
|
|
868
|
+
/**
|
|
869
|
+
* GET /api/claude/specship-impact?range=&project=
|
|
870
|
+
*
|
|
871
|
+
* Aggregate SpecShip token-impact metrics: how many tokens specship calls
|
|
872
|
+
* spent vs. how many tokens' worth of Read calls they displaced (saved),
|
|
873
|
+
* with per-prompt dedup so a file referenced by two specship calls in the
|
|
874
|
+
* same prompt counts only once. See packages/server/src/ingest/impact-query.ts
|
|
875
|
+
* for the full algorithm.
|
|
876
|
+
*
|
|
877
|
+
* Query params:
|
|
878
|
+
* range — 'today' | 'week' (default) | 'month' | 'all'
|
|
879
|
+
* project — project slug (decoded to path) or raw path; omit for all projects
|
|
880
|
+
*/
|
|
881
|
+
app.get('/api/claude/specship-impact', async (req, reply) => {
|
|
882
|
+
const cg = requirePrimary(reply);
|
|
883
|
+
if (!cg)
|
|
884
|
+
return;
|
|
885
|
+
const db = getDb(cg);
|
|
886
|
+
const since = rangeStart(rangeKey(req.query.range));
|
|
887
|
+
const project = req.query.project ? normalizeProjectFilter(req.query.project) : undefined;
|
|
888
|
+
return computeSpecshipImpact(db, { since, project });
|
|
889
|
+
});
|
|
858
890
|
/**
|
|
859
891
|
* Force a one-shot ingest pass. Useful for "Refresh" button in the UI.
|
|
860
892
|
*/
|