@felan-ai/ext-insights 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +9 -0
  3. package/README.md +31 -0
  4. package/dist/analytics.d.ts +3 -0
  5. package/dist/analytics.d.ts.map +1 -0
  6. package/dist/analytics.js +555 -0
  7. package/dist/analytics.js.map +1 -0
  8. package/dist/cli.d.ts +21 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +70 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/contracts.d.ts +20 -0
  13. package/dist/contracts.d.ts.map +1 -0
  14. package/dist/contracts.js +2 -0
  15. package/dist/contracts.js.map +1 -0
  16. package/dist/facets.d.ts +19 -0
  17. package/dist/facets.d.ts.map +1 -0
  18. package/dist/facets.js +60 -0
  19. package/dist/facets.js.map +1 -0
  20. package/dist/generated/report-app.d.ts +4 -0
  21. package/dist/generated/report-app.d.ts.map +1 -0
  22. package/dist/generated/report-app.js +5 -0
  23. package/dist/generated/report-app.js.map +1 -0
  24. package/dist/index.d.ts +14 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +114 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/markdown.d.ts +3 -0
  29. package/dist/markdown.d.ts.map +1 -0
  30. package/dist/markdown.js +169 -0
  31. package/dist/markdown.js.map +1 -0
  32. package/dist/parser.d.ts +3 -0
  33. package/dist/parser.d.ts.map +1 -0
  34. package/dist/parser.js +309 -0
  35. package/dist/parser.js.map +1 -0
  36. package/dist/rage.d.ts +12 -0
  37. package/dist/rage.d.ts.map +1 -0
  38. package/dist/rage.js +35 -0
  39. package/dist/rage.js.map +1 -0
  40. package/dist/report-app/logo.d.ts +2 -0
  41. package/dist/report-app/logo.d.ts.map +1 -0
  42. package/dist/report-app/logo.js +2 -0
  43. package/dist/report-app/logo.js.map +1 -0
  44. package/dist/report-app/utils.d.ts +7 -0
  45. package/dist/report-app/utils.d.ts.map +1 -0
  46. package/dist/report-app/utils.js +60 -0
  47. package/dist/report-app/utils.js.map +1 -0
  48. package/dist/report.d.ts +3 -0
  49. package/dist/report.d.ts.map +1 -0
  50. package/dist/report.js +7 -0
  51. package/dist/report.js.map +1 -0
  52. package/dist/types.d.ts +354 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -0
  56. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ygncode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ @felan-ai/ext-insights
2
+
3
+ Includes code adapted from packages/pi-insights in
4
+ https://github.com/mslavov/pi-extensions at source commit
5
+ 7e72e509fe45a5a87c4c2e176cb711de994a8c1d.
6
+
7
+ Copyright (c) 2026 Milko Slavov
8
+ Licensed under the MIT License included in this package.
9
+
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @felan-ai/ext-insights
2
+
3
+ Portable session analytics for Felan. The package owns parsing and analytics;
4
+ the host owns session discovery, bounded reads, report storage, and opening a
5
+ report through `InsightsHost`.
6
+
7
+ The package does not access the filesystem or ambient Pi directories directly.
8
+ Hosts must keep session visibility, transcript size, cache retention, and
9
+ optional model facet submission explicit.
10
+
11
+ The generated report is a self-contained `file://` dashboard. It includes the
12
+ Felan-branded activity calendar, daily/hourly/model/project/rage charts,
13
+ date presets and custom ranges, session search, sortable project breakdowns,
14
+ and light/dark theme switching. When the local host has Savings measurements,
15
+ the report also includes filterable avoided-cost, producer, category, project,
16
+ daily, and measurement-detail breakdowns. Savings are estimated
17
+ API-equivalent cost avoided, not an invoice or guaranteed billing reduction;
18
+ unpriced measurements are shown as incomplete.
19
+
20
+ ## Development
21
+
22
+ ```sh
23
+ pnpm --filter @felan-ai/ext-insights build
24
+ pnpm --filter @felan-ai/ext-insights type-check
25
+ pnpm --filter @felan-ai/ext-insights test
26
+ ```
27
+
28
+ ## Attribution
29
+
30
+ This package adapts the MIT-licensed `pi-insights` implementation. See
31
+ [NOTICE](NOTICE) and [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ import type { ParsedSession, Analytics } from "./types.js";
2
+ export declare function computeAnalytics(sessions: ParsedSession[]): Analytics;
3
+ //# sourceMappingURL=analytics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,SAAS,EAUV,MAAM,YAAY,CAAC;AAKpB,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,SAAS,CAsOrE"}
@@ -0,0 +1,555 @@
1
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
2
+ const DECAY_HALF_LIFE_DAYS = 10;
3
+ export function computeAnalytics(sessions) {
4
+ if (sessions.length === 0) {
5
+ return emptyAnalytics();
6
+ }
7
+ const sorted = [...sessions].sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
8
+ const totalSessions = sorted.length;
9
+ const totalMessages = sorted.reduce((s, sess) => s + sess.messageCount, 0);
10
+ const totalTokens = sorted.reduce((s, sess) => s + sess.tokenUsage.total, 0);
11
+ const totalCost = sorted.reduce((s, sess) => s + sess.cost.total, 0);
12
+ const totalDuration = sorted.reduce((s, sess) => s + sess.duration, 0);
13
+ const startDate = sorted[0].startTime;
14
+ const endDate = sorted[sorted.length - 1].endTime;
15
+ // Daily stats
16
+ const dailyMap = new Map();
17
+ for (const sess of sorted) {
18
+ const date = sess.startTime.toISOString().split("T")[0];
19
+ const existing = dailyMap.get(date);
20
+ if (existing) {
21
+ existing.sessions++;
22
+ existing.messages += sess.messageCount;
23
+ existing.tokens += sess.tokenUsage.total;
24
+ existing.cost += sess.cost.total;
25
+ }
26
+ else {
27
+ dailyMap.set(date, {
28
+ date,
29
+ sessions: 1,
30
+ messages: sess.messageCount,
31
+ tokens: sess.tokenUsage.total,
32
+ cost: sess.cost.total,
33
+ });
34
+ }
35
+ }
36
+ const dailyStats = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date));
37
+ // Project stats
38
+ const projectMap = new Map();
39
+ for (const sess of sorted) {
40
+ const existing = projectMap.get(sess.projectName);
41
+ if (existing) {
42
+ existing.sessions++;
43
+ existing.messages += sess.messageCount;
44
+ existing.tokens += sess.tokenUsage.total;
45
+ existing.cost += sess.cost.total;
46
+ existing.duration += sess.duration;
47
+ }
48
+ else {
49
+ projectMap.set(sess.projectName, {
50
+ name: sess.projectName,
51
+ sessions: 1,
52
+ messages: sess.messageCount,
53
+ tokens: sess.tokenUsage.total,
54
+ cost: sess.cost.total,
55
+ duration: sess.duration,
56
+ });
57
+ }
58
+ }
59
+ const projectStats = Array.from(projectMap.values()).sort((a, b) => b.messages - a.messages);
60
+ // Model stats
61
+ const modelMap = new Map();
62
+ let modelSwitchCount = 0;
63
+ for (const sess of sorted) {
64
+ const sessModels = Object.keys(sess.models);
65
+ if (sessModels.length > 1)
66
+ modelSwitchCount++;
67
+ for (const [name, stats] of Object.entries(sess.models)) {
68
+ const existing = modelMap.get(name);
69
+ if (existing) {
70
+ existing.count += stats.count;
71
+ existing.tokens += stats.tokens;
72
+ existing.cost += stats.cost;
73
+ }
74
+ else {
75
+ modelMap.set(name, { name, count: stats.count, tokens: stats.tokens, cost: stats.cost, avgDuration: 0 });
76
+ }
77
+ }
78
+ }
79
+ // Only include models that actually generated tokens (filters model_change-only entries)
80
+ const modelStats = Array.from(modelMap.values())
81
+ .filter(m => m.tokens > 0)
82
+ .sort((a, b) => b.tokens - a.tokens);
83
+ // Tool usage
84
+ const toolMap = new Map();
85
+ for (const sess of sorted) {
86
+ for (const [tool, count] of Object.entries(sess.toolUsage)) {
87
+ toolMap.set(tool, (toolMap.get(tool) ?? 0) + count);
88
+ }
89
+ }
90
+ const topTools = Array.from(toolMap.entries())
91
+ .map(([name, count]) => ({ name, count }))
92
+ .sort((a, b) => b.count - a.count)
93
+ .slice(0, 10);
94
+ // Thinking levels
95
+ const thinkingMap = new Map();
96
+ for (const sess of sorted) {
97
+ for (const [level, count] of Object.entries(sess.thinkingLevels)) {
98
+ thinkingMap.set(level, (thinkingMap.get(level) ?? 0) + count);
99
+ }
100
+ }
101
+ const thinkingLevelDistribution = Array.from(thinkingMap.entries())
102
+ .map(([name, count]) => ({ name, count }))
103
+ .sort((a, b) => b.count - a.count);
104
+ // Stop reasons
105
+ const stopMap = new Map();
106
+ for (const sess of sorted) {
107
+ for (const [reason, count] of Object.entries(sess.stopReasons)) {
108
+ stopMap.set(reason, (stopMap.get(reason) ?? 0) + count);
109
+ }
110
+ }
111
+ const stopReasonDistribution = Array.from(stopMap.entries())
112
+ .map(([name, count]) => ({ name, count }))
113
+ .sort((a, b) => b.count - a.count);
114
+ // Hourly distribution (session start hours)
115
+ const hourlyMap = new Map();
116
+ for (let i = 0; i < 24; i++)
117
+ hourlyMap.set(i, 0);
118
+ for (const sess of sorted) {
119
+ const hour = sess.startTime.getHours();
120
+ hourlyMap.set(hour, (hourlyMap.get(hour) ?? 0) + 1);
121
+ }
122
+ const hourlyDistribution = Array.from(hourlyMap.entries())
123
+ .map(([hour, count]) => ({ hour, count }))
124
+ .sort((a, b) => a.hour - b.hour);
125
+ // Rage stats
126
+ const rageByModel = new Map();
127
+ const rageByHour = new Map();
128
+ const rageByProject = new Map();
129
+ const rageByWord = new Map();
130
+ let rageTotalHits = 0;
131
+ let rageMsgsWithSwears = 0;
132
+ for (const sess of sorted) {
133
+ const seenMsgKeys = new Set();
134
+ for (const hit of sess.rageHits) {
135
+ rageTotalHits++;
136
+ const msgKey = `${sess.id}-${hit.msgIndex}`;
137
+ if (!seenMsgKeys.has(msgKey)) {
138
+ rageMsgsWithSwears++;
139
+ seenMsgKeys.add(msgKey);
140
+ }
141
+ rageByModel.set(hit.model, (rageByModel.get(hit.model) ?? 0) + 1);
142
+ if (hit.hour >= 0)
143
+ rageByHour.set(hit.hour, (rageByHour.get(hit.hour) ?? 0) + 1);
144
+ rageByProject.set(sess.projectName, (rageByProject.get(sess.projectName) ?? 0) + 1);
145
+ const existing = rageByWord.get(hit.word);
146
+ if (existing) {
147
+ existing.count++;
148
+ }
149
+ else {
150
+ rageByWord.set(hit.word, { group: hit.group, count: 1 });
151
+ }
152
+ }
153
+ }
154
+ const rageStats = {
155
+ total: rageTotalHits,
156
+ messagesWithSwears: rageMsgsWithSwears,
157
+ byModel: Array.from(rageByModel.entries())
158
+ .map(([name, count]) => ({ name, count }))
159
+ .sort((a, b) => b.count - a.count),
160
+ byHour: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: rageByHour.get(h) ?? 0 })),
161
+ byProject: Array.from(rageByProject.entries())
162
+ .map(([name, count]) => ({ name, count }))
163
+ .sort((a, b) => b.count - a.count),
164
+ topWords: Array.from(rageByWord.entries())
165
+ .map(([word, { group, count }]) => ({ word, group, count }))
166
+ .sort((a, b) => b.count - a.count)
167
+ .slice(0, 20),
168
+ };
169
+ const temporal = computeTemporalInsights(sorted);
170
+ const modelEfficiency = computeModelEfficiency(sorted);
171
+ const analysis = computeDeterministicAnalysis(sorted, projectStats, modelStats, modelSwitchCount, rageStats, temporal, modelEfficiency);
172
+ return {
173
+ totalSessions,
174
+ totalMessages,
175
+ totalTokens,
176
+ totalCost,
177
+ totalDuration,
178
+ avgSessionDuration: Math.round(totalDuration / totalSessions),
179
+ avgMessagesPerSession: Math.round(totalMessages / totalSessions),
180
+ dateRange: {
181
+ start: startDate.toISOString().split("T")[0],
182
+ end: endDate.toISOString().split("T")[0],
183
+ },
184
+ dailyStats,
185
+ projectStats,
186
+ modelStats,
187
+ topTools,
188
+ thinkingLevelDistribution,
189
+ stopReasonDistribution,
190
+ hourlyDistribution,
191
+ modelSwitchCount,
192
+ rageStats,
193
+ temporal,
194
+ modelEfficiency,
195
+ analysis,
196
+ sessions: sorted.map(s => ({
197
+ id: s.id,
198
+ cwd: s.cwd,
199
+ projectName: s.projectName,
200
+ startTime: s.startTime.toISOString(),
201
+ endTime: s.endTime.toISOString(),
202
+ duration: s.duration,
203
+ messageCount: s.messageCount,
204
+ userMessageCount: s.userMessageCount,
205
+ assistantMessageCount: s.assistantMessageCount,
206
+ toolCallCount: s.toolCallCount,
207
+ tokenUsage: s.tokenUsage,
208
+ cost: s.cost,
209
+ models: s.models,
210
+ providers: s.providers,
211
+ thinkingLevels: s.thinkingLevels,
212
+ toolUsage: s.toolUsage,
213
+ stopReasons: s.stopReasons,
214
+ toolCallErrors: s.toolCallErrors,
215
+ hasError: s.hasError,
216
+ rageHits: s.rageHits,
217
+ metadata: s.metadata,
218
+ })),
219
+ };
220
+ }
221
+ function computeTemporalInsights(sorted) {
222
+ const end = sorted[sorted.length - 1].endTime;
223
+ const currentStart = new Date(end.getTime() - 7 * MS_PER_DAY);
224
+ const previousStart = new Date(end.getTime() - 14 * MS_PER_DAY);
225
+ const current = sorted.filter(sess => sess.startTime >= currentStart && sess.startTime <= end);
226
+ const previous = sorted.filter(sess => sess.startTime >= previousStart && sess.startTime < currentStart);
227
+ const currentTotals = summarizeSessions(current);
228
+ const previousTotals = summarizeSessions(previous);
229
+ return {
230
+ generatedAt: new Date().toISOString(),
231
+ decayHalfLifeDays: DECAY_HALF_LIFE_DAYS,
232
+ decayWeightedActivity: computeDecayWeightedActivity(sorted, end),
233
+ weekOverWeek: {
234
+ currentStart: currentStart.toISOString().split("T")[0],
235
+ previousStart: previousStart.toISOString().split("T")[0],
236
+ sessionsDelta: currentTotals.sessions - previousTotals.sessions,
237
+ costDelta: currentTotals.cost - previousTotals.cost,
238
+ toolErrorDelta: currentTotals.toolErrors - previousTotals.toolErrors,
239
+ },
240
+ trajectory: {
241
+ cost: compareTrend(currentTotals.cost, previousTotals.cost, true),
242
+ errors: compareTrend(currentTotals.toolErrors, previousTotals.toolErrors, true),
243
+ },
244
+ anomalies: detectAnomalies(sorted),
245
+ deterministicFriction: computeDeterministicFriction(currentTotals, previousTotals),
246
+ };
247
+ }
248
+ function computeModelEfficiency(sorted) {
249
+ const modelMap = new Map();
250
+ for (const sess of sorted) {
251
+ for (const [model, stats] of Object.entries(sess.models)) {
252
+ if (stats.tokens <= 0)
253
+ continue;
254
+ const existing = modelMap.get(model) ?? { tokens: 0, cost: 0, messages: 0, sessions: 0, duration: 0, toolErrors: 0 };
255
+ existing.tokens += stats.tokens;
256
+ existing.cost += stats.cost;
257
+ existing.messages += stats.count;
258
+ existing.sessions += 1;
259
+ existing.duration += sess.duration;
260
+ existing.toolErrors += sess.toolCallErrors;
261
+ modelMap.set(model, existing);
262
+ }
263
+ }
264
+ const models = Array.from(modelMap.entries()).map(([model, stats]) => ({
265
+ model,
266
+ tokens: stats.tokens,
267
+ cost: stats.cost,
268
+ costPerToken: stats.tokens > 0 ? stats.cost / stats.tokens : 0,
269
+ costPerMessage: stats.messages > 0 ? stats.cost / stats.messages : 0,
270
+ messages: stats.messages,
271
+ sessions: stats.sessions,
272
+ avgSessionDuration: stats.sessions > 0 ? Math.round(stats.duration / stats.sessions) : 0,
273
+ toolErrorRate: stats.sessions > 0 ? stats.toolErrors / stats.sessions : 0,
274
+ })).sort((a, b) => b.cost - a.cost);
275
+ return {
276
+ generatedAt: new Date().toISOString(),
277
+ models,
278
+ recommendations: modelEfficiencyRecommendations(models),
279
+ };
280
+ }
281
+ function computeDeterministicAnalysis(sorted, projectStats, modelStats, modelSwitchCount, rageStats, temporal, modelEfficiency) {
282
+ const totalSessions = sorted.length;
283
+ const totalCost = sorted.reduce((sum, sess) => sum + sess.cost.total, 0);
284
+ const totalMessages = sorted.reduce((sum, sess) => sum + sess.messageCount, 0);
285
+ const takeaways = [];
286
+ const recommendations = [];
287
+ const stopDoing = [];
288
+ const weekOverWeek = temporal.weekOverWeek;
289
+ if (weekOverWeek) {
290
+ takeaways.push({
291
+ severity: "info",
292
+ title: "Activity change",
293
+ detail: describeDelta("session", weekOverWeek.sessionsDelta, "in the current 7-day window compared with the previous 7 days."),
294
+ });
295
+ }
296
+ const topProject = projectStats[0];
297
+ if (topProject) {
298
+ takeaways.push({
299
+ severity: "info",
300
+ title: "Primary project",
301
+ detail: `${topProject.name} accounts for ${topProject.sessions} of ${totalSessions} sessions and ${formatPercent(topProject.cost, totalCost)} of observed spend.`,
302
+ });
303
+ }
304
+ const primaryModel = modelStats[0];
305
+ if (primaryModel) {
306
+ takeaways.push({
307
+ severity: "info",
308
+ title: "Primary model",
309
+ detail: `${primaryModel.name} produced ${formatPercent(primaryModel.tokens, sorted.reduce((sum, sess) => sum + sess.tokenUsage.total, 0))} of observed tokens.`,
310
+ });
311
+ }
312
+ if (temporal.trajectory?.errors) {
313
+ takeaways.push({
314
+ severity: temporal.trajectory.errors === "worsening" ? "warning" : "info",
315
+ title: "Tool-error trend",
316
+ detail: `Tool-error trend is ${temporal.trajectory.errors}.`,
317
+ });
318
+ }
319
+ if (weekOverWeek && temporal.trajectory?.cost === "worsening" && weekOverWeek.costDelta > 0) {
320
+ recommendations.push({
321
+ title: "Audit recent cost drivers",
322
+ detail: `Recent spend increased by ${formatCurrency(weekOverWeek.costDelta)}. Review the most expensive recent sessions before changing default models or prompts.`,
323
+ category: "workflow",
324
+ });
325
+ }
326
+ for (const recommendation of modelEfficiency.recommendations) {
327
+ recommendations.push({ title: "Tune model usage", detail: recommendation, category: "model" });
328
+ }
329
+ for (const signal of temporal.deterministicFriction?.ongoing ?? []) {
330
+ if (signal.title === "Tool errors") {
331
+ recommendations.push({
332
+ title: "Fix tool-error hotspots",
333
+ detail: `${signal.detail} Check failing command/tool patterns before repeating the same workflow.`,
334
+ category: "workflow",
335
+ });
336
+ stopDoing.push({
337
+ title: "Stop retrying failed tool calls blindly",
338
+ detail: "Inspect the first failure and adjust inputs before rerunning similar commands.",
339
+ category: "stop",
340
+ });
341
+ }
342
+ else if (signal.title === "Slow responses") {
343
+ recommendations.push({
344
+ title: "Split slow sessions into checkpoints",
345
+ detail: `${signal.detail} Smaller scopes usually make progress easier to verify and resume.`,
346
+ category: "workflow",
347
+ });
348
+ }
349
+ else if (signal.title === "Interruptions") {
350
+ recommendations.push({
351
+ title: "Clarify success criteria before long runs",
352
+ detail: `${signal.detail} Add explicit verification targets before starting broad tasks.`,
353
+ category: "workflow",
354
+ });
355
+ }
356
+ }
357
+ if (totalSessions >= 3 && modelSwitchCount / totalSessions >= 0.25) {
358
+ recommendations.push({
359
+ title: "Make model switching deliberate",
360
+ detail: `${modelSwitchCount} of ${totalSessions} sessions used multiple models. Split lightweight lookups from high-reasoning implementation work when possible.`,
361
+ category: "model",
362
+ });
363
+ stopDoing.push({
364
+ title: "Stop switching models mid-session without a reason",
365
+ detail: "Use a stronger model for planning/review and a faster model for routine edits, rather than mixing ad hoc within one task.",
366
+ category: "stop",
367
+ });
368
+ }
369
+ if (topProject && totalCost > 0 && topProject.cost / totalCost >= 0.5 && projectStats.length > 1) {
370
+ recommendations.push({
371
+ title: "Review the dominant project",
372
+ detail: `${topProject.name} represents ${formatPercent(topProject.cost, totalCost)} of spend. Check whether repeated work there can be captured in tests, docs, or reusable commands.`,
373
+ category: "workflow",
374
+ });
375
+ }
376
+ if (totalSessions > 0 && totalMessages / totalSessions >= 25) {
377
+ recommendations.push({
378
+ title: "Checkpoint long conversations",
379
+ detail: `Average depth is ${Math.round(totalMessages / totalSessions)} messages per session. Save plans, test commands, and open questions before context gets noisy.`,
380
+ category: "workflow",
381
+ });
382
+ }
383
+ if (rageStats.messagesWithSwears > 0) {
384
+ stopDoing.push({
385
+ title: "Stop pushing through frustration signals",
386
+ detail: `${rageStats.messagesWithSwears} messages contained rage language. Treat these as points to narrow scope, inspect logs, or ask for clarification.`,
387
+ category: "stop",
388
+ });
389
+ }
390
+ if (recommendations.length === 0 && topProject && primaryModel) {
391
+ recommendations.push({
392
+ title: "Keep monitoring the main workflow",
393
+ detail: `No deterministic friction stood out. Continue watching ${topProject.name} and ${primaryModel.name} for changes in spend, errors, and session length.`,
394
+ category: "workflow",
395
+ });
396
+ }
397
+ return {
398
+ generatedAt: new Date().toISOString(),
399
+ takeaways: dedupeFlags(takeaways),
400
+ recommendations: dedupeRecommendationObjects(recommendations),
401
+ stopDoing: dedupeRecommendationObjects(stopDoing),
402
+ };
403
+ }
404
+ function summarizeSessions(sessions) {
405
+ return sessions.reduce((summary, sess) => {
406
+ summary.sessions++;
407
+ summary.messages += sess.messageCount;
408
+ summary.tokens += sess.tokenUsage.total;
409
+ summary.cost += sess.cost.total;
410
+ summary.toolErrors += sess.toolCallErrors;
411
+ summary.interruptions += sess.metadata?.userInterruptions ?? 0;
412
+ summary.slowResponses += (sess.metadata?.responseTimesMs ?? []).filter(ms => ms > 60_000).length;
413
+ return summary;
414
+ }, { sessions: 0, messages: 0, tokens: 0, cost: 0, toolErrors: 0, interruptions: 0, slowResponses: 0 });
415
+ }
416
+ function computeDecayWeightedActivity(sorted, end) {
417
+ return sorted.reduce((weighted, sess) => {
418
+ const ageDays = Math.max(0, (end.getTime() - sess.startTime.getTime()) / MS_PER_DAY);
419
+ const weight = Math.pow(0.5, ageDays / DECAY_HALF_LIFE_DAYS);
420
+ weighted.sessions += weight;
421
+ weighted.messages += sess.messageCount * weight;
422
+ weighted.tokens += sess.tokenUsage.total * weight;
423
+ weighted.cost += sess.cost.total * weight;
424
+ return weighted;
425
+ }, { sessions: 0, messages: 0, tokens: 0, cost: 0 });
426
+ }
427
+ function compareTrend(current, previous, lowerIsBetter = false) {
428
+ if (previous === 0 && current === 0)
429
+ return "stable";
430
+ if (previous === 0)
431
+ return lowerIsBetter ? "worsening" : "improving";
432
+ const ratio = current / previous;
433
+ if (ratio > 1.1)
434
+ return lowerIsBetter ? "worsening" : "improving";
435
+ if (ratio < 0.9)
436
+ return lowerIsBetter ? "improving" : "worsening";
437
+ return "stable";
438
+ }
439
+ function detectAnomalies(sorted) {
440
+ return [
441
+ ...metricAnomalies(sorted, sess => sess.cost.total, "Cost spike", "Session cost is unusually high compared with nearby history."),
442
+ ...metricAnomalies(sorted, sess => sess.toolCallErrors, "Tool error spike", "Session tool errors are unusually high compared with nearby history."),
443
+ ];
444
+ }
445
+ function metricAnomalies(sessions, selector, title, detail) {
446
+ if (sessions.length < 3)
447
+ return [];
448
+ const values = sessions.map(selector);
449
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
450
+ const variance = values.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / values.length;
451
+ const threshold = mean + Math.sqrt(variance) * 2;
452
+ return sessions
453
+ .filter(sess => selector(sess) > 0 && selector(sess) > threshold)
454
+ .map(sess => ({ severity: "warning", title, detail, sessionIds: [sess.id] }));
455
+ }
456
+ function computeDeterministicFriction(current, previous) {
457
+ const ongoing = [];
458
+ const resolved = [];
459
+ collectFriction("Tool errors", current.toolErrors, previous.toolErrors, ongoing, resolved);
460
+ collectFriction("Slow responses", current.slowResponses, previous.slowResponses, ongoing, resolved);
461
+ collectFriction("Interruptions", current.interruptions, previous.interruptions, ongoing, resolved);
462
+ return { ongoing, resolved };
463
+ }
464
+ function collectFriction(label, current, previous, ongoing, resolved) {
465
+ if (current > 0) {
466
+ ongoing.push({ severity: "warning", title: label, detail: `${current} recent ${label.toLowerCase()} signal${current === 1 ? "" : "s"} detected.` });
467
+ }
468
+ else if (previous > 0) {
469
+ resolved.push({ severity: "info", title: label, detail: `No recent ${label.toLowerCase()} signals after ${previous} in the previous period.` });
470
+ }
471
+ }
472
+ function modelEfficiencyRecommendations(models) {
473
+ if (models.length === 0)
474
+ return [];
475
+ const recommendations = [];
476
+ const byCostPerToken = [...models].sort((a, b) => b.costPerToken - a.costPerToken);
477
+ const mostExpensive = byCostPerToken[0];
478
+ const median = byCostPerToken[Math.floor(byCostPerToken.length / 2)]?.costPerToken ?? mostExpensive.costPerToken;
479
+ if (median > 0 && mostExpensive.costPerToken > median * 2) {
480
+ recommendations.push(`${mostExpensive.model} has the highest observed cost per token; reserve it for tasks that need that capability.`);
481
+ }
482
+ const errorProne = models.find(model => (model.toolErrorRate ?? 0) > 0.2);
483
+ if (errorProne) {
484
+ recommendations.push(`${errorProne.model} appears in sessions with frequent tool errors; inspect prompts or tool usage before increasing usage.`);
485
+ }
486
+ return recommendations;
487
+ }
488
+ function describeDelta(noun, delta, suffix) {
489
+ if (delta === 0)
490
+ return `No ${noun} change ${suffix}`;
491
+ const abs = Math.abs(delta);
492
+ const plural = abs === 1 ? noun : `${noun}s`;
493
+ return `${abs} ${delta > 0 ? "more" : "fewer"} ${plural} ${suffix}`;
494
+ }
495
+ function formatPercent(part, total) {
496
+ if (total <= 0)
497
+ return "0%";
498
+ return `${Math.round((part / total) * 100)}%`;
499
+ }
500
+ function formatCurrency(value) {
501
+ const abs = Math.abs(value);
502
+ const digits = abs >= 0.1 ? 2 : 4;
503
+ return `${value < 0 ? "-" : ""}$${abs.toFixed(digits)}`;
504
+ }
505
+ function dedupeFlags(flags) {
506
+ const seen = new Set();
507
+ return flags.filter(flag => {
508
+ const key = `${flag.title}\n${flag.detail}`;
509
+ if (seen.has(key))
510
+ return false;
511
+ seen.add(key);
512
+ return true;
513
+ });
514
+ }
515
+ function dedupeRecommendationObjects(recommendations) {
516
+ const seen = new Set();
517
+ return recommendations.filter(recommendation => {
518
+ const key = `${recommendation.title}\n${recommendation.detail}`;
519
+ if (seen.has(key))
520
+ return false;
521
+ seen.add(key);
522
+ return true;
523
+ });
524
+ }
525
+ function emptyAnalytics() {
526
+ const emptyRage = {
527
+ total: 0,
528
+ messagesWithSwears: 0,
529
+ byModel: [],
530
+ byHour: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: 0 })),
531
+ byProject: [],
532
+ topWords: [],
533
+ };
534
+ return {
535
+ totalSessions: 0,
536
+ totalMessages: 0,
537
+ totalTokens: 0,
538
+ totalCost: 0,
539
+ totalDuration: 0,
540
+ avgSessionDuration: 0,
541
+ avgMessagesPerSession: 0,
542
+ dateRange: { start: "", end: "" },
543
+ dailyStats: [],
544
+ projectStats: [],
545
+ modelStats: [],
546
+ topTools: [],
547
+ thinkingLevelDistribution: [],
548
+ stopReasonDistribution: [],
549
+ hourlyDistribution: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: 0 })),
550
+ modelSwitchCount: 0,
551
+ rageStats: emptyRage,
552
+ sessions: [],
553
+ };
554
+ }
555
+ //# sourceMappingURL=analytics.js.map