@axplusb/kepler 0.0.1 → 1.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.
- package/README.md +82 -0
- package/package.json +36 -4
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +52 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +142 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +159 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +52 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +182 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/claude-reader.ts +594 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +277 -0
- package/src/config/cli-args.mjs +173 -0
- package/src/config/env.mjs +263 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/retriever.mjs +270 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/core/agent-loop.mjs +480 -0
- package/src/core/approval.mjs +273 -0
- package/src/core/backend-url.mjs +57 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +149 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/headless.mjs +168 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +351 -0
- package/src/core/local-agent.mjs +429 -0
- package/src/core/local-store.mjs +325 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +98 -0
- package/src/core/pricing.mjs +314 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/safety.mjs +200 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +317 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +367 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/core/tool-executor.mjs +725 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +370 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +573 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/terminal/agents.mjs +177 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +421 -0
- package/src/terminal/main.mjs +150 -0
- package/src/terminal/repl.mjs +1484 -0
- package/src/terminal/tool-display.mjs +58 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +132 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/banner.mjs +237 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/formatter.mjs +379 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/slash-commands.mjs +258 -0
- package/index.js +0 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import useSWR from 'swr'
|
|
4
|
+
import { TopBar } from '@/components/layout/top-bar'
|
|
5
|
+
import { ToolRankingChart } from '@/components/tools/tool-ranking-chart'
|
|
6
|
+
import { McpServerPanel } from '@/components/tools/mcp-server-panel'
|
|
7
|
+
import { FeatureAdoptionTable } from '@/components/tools/feature-adoption-table'
|
|
8
|
+
import { VersionHistoryTable } from '@/components/tools/version-history-table'
|
|
9
|
+
import { CATEGORY_COLORS, CATEGORY_LABELS } from '@/lib/tool-categories'
|
|
10
|
+
import type { ToolsAnalytics } from '@/types/claude'
|
|
11
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
12
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
13
|
+
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
14
|
+
import { Badge } from '@/components/ui/badge'
|
|
15
|
+
import { AlertTriangle, Wrench, Server, Zap, GitBranch } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
const fetcher = (url: string) =>
|
|
18
|
+
fetch(url).then(r => { if (!r.ok) throw new Error(`API error ${r.status}`); return r.json() })
|
|
19
|
+
|
|
20
|
+
export default function ToolsPage() {
|
|
21
|
+
const { data, error, isLoading } = useSWR<ToolsAnalytics>('/api/tools', fetcher, { refreshInterval: 5_000 })
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col min-h-screen">
|
|
25
|
+
<TopBar title="Tools & Features" subtitle="Every tool call, MCP server, and feature" />
|
|
26
|
+
<div className="p-6 space-y-6">
|
|
27
|
+
|
|
28
|
+
{error && (
|
|
29
|
+
<Alert variant="destructive">
|
|
30
|
+
<AlertTriangle className="h-4 w-4" />
|
|
31
|
+
<AlertDescription>Error loading data: {String(error)}</AlertDescription>
|
|
32
|
+
</Alert>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{isLoading && (
|
|
36
|
+
<div className="space-y-4">
|
|
37
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
38
|
+
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-28 rounded-xl" />)}
|
|
39
|
+
</div>
|
|
40
|
+
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48 rounded-xl" />)}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{data && (
|
|
45
|
+
<>
|
|
46
|
+
{/* Hero stat cards */}
|
|
47
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
48
|
+
<Card>
|
|
49
|
+
<CardHeader className="pb-2">
|
|
50
|
+
<CardDescription className="flex items-center gap-2">
|
|
51
|
+
<Zap className="w-4 h-4" /> Tool Calls
|
|
52
|
+
</CardDescription>
|
|
53
|
+
<CardTitle className="text-3xl font-bold tabular-nums text-[#d97706]">
|
|
54
|
+
{data.total_tool_calls.toLocaleString()}
|
|
55
|
+
</CardTitle>
|
|
56
|
+
</CardHeader>
|
|
57
|
+
<CardContent>
|
|
58
|
+
<p className="text-xs text-muted-foreground">Total all time</p>
|
|
59
|
+
</CardContent>
|
|
60
|
+
</Card>
|
|
61
|
+
|
|
62
|
+
<Card>
|
|
63
|
+
<CardHeader className="pb-2">
|
|
64
|
+
<CardDescription className="flex items-center gap-2">
|
|
65
|
+
<Wrench className="w-4 h-4" /> Unique Tools
|
|
66
|
+
</CardDescription>
|
|
67
|
+
<CardTitle className="text-3xl font-bold tabular-nums">
|
|
68
|
+
{data.tools.length}
|
|
69
|
+
</CardTitle>
|
|
70
|
+
</CardHeader>
|
|
71
|
+
<CardContent>
|
|
72
|
+
<p className="text-xs text-muted-foreground">Distinct tools used</p>
|
|
73
|
+
</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
|
|
76
|
+
<Card>
|
|
77
|
+
<CardHeader className="pb-2">
|
|
78
|
+
<CardDescription className="flex items-center gap-2">
|
|
79
|
+
<Server className="w-4 h-4" /> MCP Servers
|
|
80
|
+
</CardDescription>
|
|
81
|
+
<CardTitle className="text-3xl font-bold tabular-nums text-[#34d399]">
|
|
82
|
+
{data.mcp_servers.length}
|
|
83
|
+
</CardTitle>
|
|
84
|
+
</CardHeader>
|
|
85
|
+
<CardContent>
|
|
86
|
+
<p className="text-xs text-muted-foreground">Connected servers</p>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
|
|
90
|
+
<Card>
|
|
91
|
+
<CardHeader className="pb-2">
|
|
92
|
+
<CardDescription className="flex items-center gap-2">
|
|
93
|
+
<AlertTriangle className="w-4 h-4" /> Errors
|
|
94
|
+
</CardDescription>
|
|
95
|
+
<CardTitle className={`text-3xl font-bold tabular-nums ${data.total_errors > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
|
|
96
|
+
{data.total_errors}
|
|
97
|
+
</CardTitle>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent>
|
|
100
|
+
<p className="text-xs text-muted-foreground">
|
|
101
|
+
{data.total_tool_calls > 0
|
|
102
|
+
? `${((data.total_errors / data.total_tool_calls) * 100).toFixed(1)}% error rate`
|
|
103
|
+
: 'No errors'}
|
|
104
|
+
</p>
|
|
105
|
+
</CardContent>
|
|
106
|
+
</Card>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Category legend */}
|
|
110
|
+
<div className="flex flex-wrap gap-2">
|
|
111
|
+
{Object.entries(CATEGORY_COLORS).map(([cat, color]) => (
|
|
112
|
+
<Badge key={cat} variant="outline" className="gap-1.5">
|
|
113
|
+
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
|
114
|
+
{CATEGORY_LABELS[cat as keyof typeof CATEGORY_LABELS]}
|
|
115
|
+
</Badge>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Tool ranking */}
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<div className="flex items-start justify-between">
|
|
123
|
+
<div>
|
|
124
|
+
<CardTitle>Tool Ranking</CardTitle>
|
|
125
|
+
<CardDescription>All tools ranked by total calls</CardDescription>
|
|
126
|
+
</div>
|
|
127
|
+
<Wrench className="w-4 h-4 text-muted-foreground mt-0.5" />
|
|
128
|
+
</div>
|
|
129
|
+
</CardHeader>
|
|
130
|
+
<CardContent>
|
|
131
|
+
<ToolRankingChart tools={data.tools} />
|
|
132
|
+
</CardContent>
|
|
133
|
+
</Card>
|
|
134
|
+
|
|
135
|
+
{/* MCP server details */}
|
|
136
|
+
{data.mcp_servers.length > 0 && (
|
|
137
|
+
<Card>
|
|
138
|
+
<CardHeader>
|
|
139
|
+
<div className="flex items-start justify-between">
|
|
140
|
+
<div>
|
|
141
|
+
<CardTitle>MCP Server Details</CardTitle>
|
|
142
|
+
<CardDescription>Connected MCP servers and their tools</CardDescription>
|
|
143
|
+
</div>
|
|
144
|
+
<Server className="w-4 h-4 text-muted-foreground mt-0.5" />
|
|
145
|
+
</div>
|
|
146
|
+
</CardHeader>
|
|
147
|
+
<CardContent>
|
|
148
|
+
<McpServerPanel servers={data.mcp_servers} />
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Feature adoption */}
|
|
154
|
+
<Card>
|
|
155
|
+
<CardHeader>
|
|
156
|
+
<CardTitle>Feature Adoption</CardTitle>
|
|
157
|
+
<CardDescription>How often advanced features are used across sessions</CardDescription>
|
|
158
|
+
</CardHeader>
|
|
159
|
+
<CardContent>
|
|
160
|
+
<FeatureAdoptionTable
|
|
161
|
+
adoption={data.feature_adoption}
|
|
162
|
+
totalSessions={(() => {
|
|
163
|
+
const first = Object.values(data.feature_adoption ?? {})[0]
|
|
164
|
+
return first ? Math.round(first.sessions / Math.max(0.001, first.pct)) : 0
|
|
165
|
+
})()}
|
|
166
|
+
/>
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
|
|
170
|
+
{/* Error analysis */}
|
|
171
|
+
{data.total_errors > 0 && (
|
|
172
|
+
<Card>
|
|
173
|
+
<CardHeader>
|
|
174
|
+
<CardTitle>Tool Error Analysis</CardTitle>
|
|
175
|
+
<CardDescription>
|
|
176
|
+
{data.total_errors} errors ·{' '}
|
|
177
|
+
{((data.total_errors / data.total_tool_calls) * 100).toFixed(1)}% error rate
|
|
178
|
+
</CardDescription>
|
|
179
|
+
</CardHeader>
|
|
180
|
+
<CardContent>
|
|
181
|
+
<div className="space-y-2.5">
|
|
182
|
+
{Object.entries(data.error_categories).sort(([, a], [, b]) => b - a).map(([cat, count]) => {
|
|
183
|
+
const max = Math.max(...Object.values(data.error_categories))
|
|
184
|
+
const width = Math.max(4, Math.round((count / max) * 100))
|
|
185
|
+
return (
|
|
186
|
+
<div key={cat} className="flex items-center gap-3">
|
|
187
|
+
<span className="text-sm text-muted-foreground w-36 truncate">{cat}</span>
|
|
188
|
+
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
189
|
+
<div className="h-full rounded-full bg-red-400/50" style={{ width: `${width}%` }} />
|
|
190
|
+
</div>
|
|
191
|
+
<span className="text-sm text-red-400 tabular-nums w-8 text-right">{count}</span>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
})}
|
|
195
|
+
</div>
|
|
196
|
+
</CardContent>
|
|
197
|
+
</Card>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Version history */}
|
|
201
|
+
{data.versions.length > 0 && (
|
|
202
|
+
<Card>
|
|
203
|
+
<CardHeader>
|
|
204
|
+
<CardTitle>Orca Version History</CardTitle>
|
|
205
|
+
<CardDescription>Versions seen across your sessions</CardDescription>
|
|
206
|
+
</CardHeader>
|
|
207
|
+
<CardContent>
|
|
208
|
+
<VersionHistoryTable versions={data.versions} />
|
|
209
|
+
</CardContent>
|
|
210
|
+
</Card>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Git branch analytics */}
|
|
214
|
+
{data.branches.length > 0 && (
|
|
215
|
+
<Card>
|
|
216
|
+
<CardHeader>
|
|
217
|
+
<div className="flex items-start justify-between">
|
|
218
|
+
<div>
|
|
219
|
+
<CardTitle>Git Branch Analytics</CardTitle>
|
|
220
|
+
<CardDescription>Most active branches by turn count</CardDescription>
|
|
221
|
+
</div>
|
|
222
|
+
<GitBranch className="w-4 h-4 text-muted-foreground mt-0.5" />
|
|
223
|
+
</div>
|
|
224
|
+
</CardHeader>
|
|
225
|
+
<CardContent>
|
|
226
|
+
<div className="space-y-2">
|
|
227
|
+
{data.branches.map(({ branch, turns }) => {
|
|
228
|
+
const max = data.branches[0]?.turns ?? 1
|
|
229
|
+
const width = Math.max(4, Math.round((turns / max) * 100))
|
|
230
|
+
return (
|
|
231
|
+
<div key={branch} className="flex items-center gap-3">
|
|
232
|
+
<span className="text-sm text-muted-foreground w-28 truncate font-mono">{branch}</span>
|
|
233
|
+
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
234
|
+
<div className="h-full rounded-full bg-[#34d399]/50" style={{ width: `${width}%` }} />
|
|
235
|
+
</div>
|
|
236
|
+
<span className="text-xs text-muted-foreground tabular-nums w-24 text-right">{turns.toLocaleString()} turns</span>
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
</CardContent>
|
|
242
|
+
</Card>
|
|
243
|
+
)}
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
package/pulse/cli.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Orca Pulse — local analytics dashboard launcher.
|
|
4
|
+
* Launches a Next.js dev server from ~/.orca-pulse/ cache directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { spawn, exec } = require('child_process')
|
|
8
|
+
const net = require('net')
|
|
9
|
+
const os = require('os')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
|
|
13
|
+
const PKG_DIR = __dirname
|
|
14
|
+
const CACHE_DIR = path.join(os.homedir(), '.orca-pulse')
|
|
15
|
+
|
|
16
|
+
// ANSI helpers — Orca cyan palette
|
|
17
|
+
const C = '\x1b[36m' // cyan
|
|
18
|
+
const C2 = '\x1b[96m' // bright cyan
|
|
19
|
+
const DIM = '\x1b[2m'
|
|
20
|
+
const B = '\x1b[1m'
|
|
21
|
+
const R = '\x1b[0m'
|
|
22
|
+
const G = '\x1b[32m'
|
|
23
|
+
|
|
24
|
+
function printBanner() {
|
|
25
|
+
const art = [
|
|
26
|
+
`${C}${B} ██████╗ ██████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗██╗ ███████╗███████╗${R}`,
|
|
27
|
+
`${C}${B}██╔═══██╗██╔══██╗██╔════╝██╔══██╗ ██╔══██╗██║ ██║██║ ██╔════╝██╔════╝${R}`,
|
|
28
|
+
`${C2}${B}██║ ██║██████╔╝██║ ███████║ ██████╔╝██║ ██║██║ ███████╗█████╗ ${R}`,
|
|
29
|
+
`${C2}${B}██║ ██║██╔══██╗██║ ██╔══██║ ██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ${R}`,
|
|
30
|
+
`${C}${B}╚██████╔╝██║ ██║╚██████╗██║ ██║ ██║ ╚██████╔╝███████╗███████║███████╗${R}`,
|
|
31
|
+
`${C}${B} ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝${R}`,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
console.log()
|
|
35
|
+
art.forEach((line) => console.log(' ' + line))
|
|
36
|
+
console.log()
|
|
37
|
+
const configDir = process.env.ORCA_CONFIG_DIR ?? path.join(os.homedir(), '.orca')
|
|
38
|
+
console.log(` ${B}${C}Orca Pulse${R} ${DIM}— real-time analytics for your Orca sessions${R}`)
|
|
39
|
+
console.log()
|
|
40
|
+
console.log(` ${DIM}Data dir:${R} ${C2}${configDir}${R}`)
|
|
41
|
+
console.log()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findFreePort(port = 3000) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const server = net.createServer()
|
|
47
|
+
server.unref()
|
|
48
|
+
server.on('error', () => resolve(findFreePort(port + 1)))
|
|
49
|
+
server.listen(port, () => server.close(() => resolve(port)))
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openBrowser(url) {
|
|
54
|
+
const cmd =
|
|
55
|
+
process.platform === 'darwin' ? `open "${url}"` :
|
|
56
|
+
process.platform === 'win32' ? `start "" "${url}"` :
|
|
57
|
+
`xdg-open "${url}"`
|
|
58
|
+
exec(cmd)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Source dirs/files to mirror into ~/.orca-pulse/
|
|
62
|
+
const SRC_DIRS = ['app', 'components', 'lib', 'types', 'public']
|
|
63
|
+
const SRC_FILES = ['next.config.ts', 'tsconfig.json', 'postcss.config.mjs', 'components.json']
|
|
64
|
+
|
|
65
|
+
function syncSource(pkg) {
|
|
66
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
67
|
+
for (const dir of SRC_DIRS) {
|
|
68
|
+
const src = path.join(PKG_DIR, dir)
|
|
69
|
+
if (fs.existsSync(src)) {
|
|
70
|
+
fs.cpSync(src, path.join(CACHE_DIR, dir), { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const file of SRC_FILES) {
|
|
74
|
+
const src = path.join(PKG_DIR, file)
|
|
75
|
+
if (fs.existsSync(src)) {
|
|
76
|
+
fs.copyFileSync(src, path.join(CACHE_DIR, file))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Write a minimal package.json with only runtime dependencies
|
|
80
|
+
fs.writeFileSync(path.join(CACHE_DIR, 'package.json'), JSON.stringify({
|
|
81
|
+
name: 'orca-pulse-runtime',
|
|
82
|
+
version: pkg.version,
|
|
83
|
+
dependencies: pkg.dependencies,
|
|
84
|
+
}, null, 2))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
printBanner()
|
|
89
|
+
|
|
90
|
+
const pkg = require(path.join(PKG_DIR, 'package.json'))
|
|
91
|
+
|
|
92
|
+
// Check whether ~/.orca-pulse/ is up-to-date for this version
|
|
93
|
+
const versionFile = path.join(CACHE_DIR, '.orca-pulse-version')
|
|
94
|
+
const cachedVersion = fs.existsSync(versionFile)
|
|
95
|
+
? fs.readFileSync(versionFile, 'utf8').trim()
|
|
96
|
+
: null
|
|
97
|
+
|
|
98
|
+
const nextCli = path.join(CACHE_DIR, 'node_modules', 'next', 'dist', 'bin', 'next')
|
|
99
|
+
const needsSetup = cachedVersion !== pkg.version || !fs.existsSync(nextCli)
|
|
100
|
+
|
|
101
|
+
if (needsSetup) {
|
|
102
|
+
console.log(` ${DIM}Setting up (first run, may take a minute)...${R}\n`)
|
|
103
|
+
|
|
104
|
+
syncSource(pkg)
|
|
105
|
+
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
const install = spawn('npm', ['install', '--prefer-offline', '--no-package-lock'], {
|
|
108
|
+
cwd: CACHE_DIR,
|
|
109
|
+
stdio: 'inherit',
|
|
110
|
+
shell: true,
|
|
111
|
+
})
|
|
112
|
+
install.on('exit', (code) =>
|
|
113
|
+
code === 0 ? resolve() : reject(new Error(`npm install failed (exit ${code})`))
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(versionFile, pkg.version)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const port = await findFreePort(3000)
|
|
121
|
+
const url = `http://localhost:${port}`
|
|
122
|
+
|
|
123
|
+
// Pass ORCA_CONFIG_DIR to the Next.js process so it reads from ~/.orca/
|
|
124
|
+
const orcaDir = process.env.ORCA_CONFIG_DIR ?? path.join(os.homedir(), '.orca')
|
|
125
|
+
const env = {
|
|
126
|
+
...process.env,
|
|
127
|
+
PORT: String(port),
|
|
128
|
+
ORCA_CONFIG_DIR: orcaDir,
|
|
129
|
+
CLAUDE_CONFIG_DIR: orcaDir,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(` ${DIM}Starting server on${R} ${C2}${B}${url}${R}\n`)
|
|
133
|
+
|
|
134
|
+
const child = spawn(process.execPath, [nextCli, 'dev', '--port', String(port)], {
|
|
135
|
+
cwd: CACHE_DIR,
|
|
136
|
+
stdio: [process.platform === 'win32' ? 'ignore' : 'inherit', 'pipe', 'pipe'],
|
|
137
|
+
env,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
let opened = false
|
|
141
|
+
|
|
142
|
+
function checkReady(text) {
|
|
143
|
+
if (!opened && /Local:|ready|started server/i.test(text)) {
|
|
144
|
+
opened = true
|
|
145
|
+
console.log(`\n ${G}${B}Ready${R} ${C}${url}${R}\n`)
|
|
146
|
+
openBrowser(url)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
child.stdout.on('data', (d) => { process.stdout.write(d); checkReady(d.toString()) })
|
|
151
|
+
child.stderr.on('data', (d) => { process.stderr.write(d); checkReady(d.toString()) })
|
|
152
|
+
|
|
153
|
+
child.on('exit', (code) => process.exit(code ?? 0))
|
|
154
|
+
|
|
155
|
+
process.on('SIGINT', () => { child.kill(); process.exit(0) })
|
|
156
|
+
process.on('SIGTERM', () => { child.kill(); process.exit(0) })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch((err) => { console.error(err); process.exit(1) })
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ResponsiveContainer } from 'recharts'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
data: Array<{ day: string; count: number }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DayOfWeekChart({ data }: Props) {
|
|
10
|
+
const max = Math.max(...data.map(d => d.count), 1)
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<h3 className="text-[13px] font-bold text-muted-foreground uppercase tracking-widest mb-3">Day of Week</h3>
|
|
14
|
+
<ResponsiveContainer width="100%" height={140}>
|
|
15
|
+
<BarChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
|
16
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
|
17
|
+
<XAxis dataKey="day" tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }} tickLine={false} axisLine={false} />
|
|
18
|
+
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} tickLine={false} axisLine={false} width={28} />
|
|
19
|
+
<Tooltip
|
|
20
|
+
contentStyle={{ background: 'var(--card)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}
|
|
21
|
+
formatter={(val: number | undefined) => [(val ?? 0).toLocaleString(), 'messages']}
|
|
22
|
+
/>
|
|
23
|
+
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
|
24
|
+
{data.map((d, i) => (
|
|
25
|
+
<Cell
|
|
26
|
+
key={i}
|
|
27
|
+
fill={d.count === max ? '#d97706' : d.count > max * 0.6 ? '#d97706aa' : '#d9770640'}
|
|
28
|
+
/>
|
|
29
|
+
))}
|
|
30
|
+
</Bar>
|
|
31
|
+
</BarChart>
|
|
32
|
+
</ResponsiveContainer>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
current: number
|
|
3
|
+
longest: number
|
|
4
|
+
totalActiveDays: number
|
|
5
|
+
mostActiveDay: string
|
|
6
|
+
mostActiveDayMsgs: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function StreakCard({ current, longest, totalActiveDays, mostActiveDay, mostActiveDayMsgs }: Props) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="grid grid-cols-2 gap-3 text-[13px]">
|
|
12
|
+
<div className="border border-border rounded p-3 bg-card">
|
|
13
|
+
<p className="text-muted-foreground uppercase tracking-wider text-[12px] mb-1">Current Streak</p>
|
|
14
|
+
<p className="text-2xl font-bold text-primary">{current}</p>
|
|
15
|
+
<p className="text-muted-foreground/60 text-[12px]">consecutive days</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="border border-border rounded p-3 bg-card">
|
|
18
|
+
<p className="text-muted-foreground uppercase tracking-wider text-[12px] mb-1">Longest Streak</p>
|
|
19
|
+
<p className="text-2xl font-bold text-blue-700 dark:text-[#60a5fa]">{longest}</p>
|
|
20
|
+
<p className="text-muted-foreground/60 text-[12px]">consecutive days</p>
|
|
21
|
+
</div>
|
|
22
|
+
<div className="border border-border rounded p-3 bg-card">
|
|
23
|
+
<p className="text-muted-foreground uppercase tracking-wider text-[12px] mb-1">Active Days</p>
|
|
24
|
+
<p className="text-2xl font-bold text-foreground">{totalActiveDays}</p>
|
|
25
|
+
<p className="text-muted-foreground/60 text-[12px]">total days with activity</p>
|
|
26
|
+
</div>
|
|
27
|
+
{mostActiveDay && (
|
|
28
|
+
<div className="border border-border rounded p-3 bg-card">
|
|
29
|
+
<p className="text-muted-foreground uppercase tracking-wider text-[12px] mb-1">Most Active Day</p>
|
|
30
|
+
<p className="text-sm font-bold text-[#34d399]">{mostActiveDay}</p>
|
|
31
|
+
<p className="text-muted-foreground/60 text-[12px]">{mostActiveDayMsgs.toLocaleString()} messages</p>
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { formatCost, formatPct, formatTokens } from '@/lib/decode'
|
|
2
|
+
import type { ModelCostBreakdown } from '@/types/claude'
|
|
3
|
+
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
models: ModelCostBreakdown[]
|
|
7
|
+
totalSavings: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CacheEfficiencyPanel({ models, totalSavings }: Props) {
|
|
11
|
+
const totalCacheRead = models.reduce((s, m) => s + m.cache_read_tokens, 0)
|
|
12
|
+
const totalInput = models.reduce((s, m) => s + m.input_tokens, 0)
|
|
13
|
+
const totalContext = totalInput + totalCacheRead
|
|
14
|
+
const hitRate = totalContext > 0 ? totalCacheRead / totalContext : 0
|
|
15
|
+
const totalCost = models.reduce((s, m) => s + m.estimated_cost, 0)
|
|
16
|
+
const wouldHavePaid = totalCost + totalSavings
|
|
17
|
+
|
|
18
|
+
const pieData = [
|
|
19
|
+
{ name: 'Cache Read', value: totalCacheRead, color: '#34d399' },
|
|
20
|
+
{ name: 'Direct Input', value: totalInput, color: 'var(--viz-sky)' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="grid grid-cols-[1fr_160px] gap-4 items-start">
|
|
25
|
+
<div className="space-y-2 text-[13px]">
|
|
26
|
+
<div className="flex items-center justify-between">
|
|
27
|
+
<span className="text-muted-foreground">Cache hit rate</span>
|
|
28
|
+
<span className="text-[#34d399] font-bold text-lg">{(hitRate * 100).toFixed(1)}%</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex items-center justify-between">
|
|
31
|
+
<span className="text-muted-foreground">Context from cache</span>
|
|
32
|
+
<span className="text-foreground font-mono">{formatTokens(totalCacheRead)}</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex items-center justify-between">
|
|
35
|
+
<span className="text-muted-foreground">Context from input</span>
|
|
36
|
+
<span className="text-foreground font-mono">{formatTokens(totalInput)}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="border-t border-border/50 pt-2 mt-2 space-y-1.5">
|
|
39
|
+
<div className="flex items-center justify-between">
|
|
40
|
+
<span className="text-muted-foreground">Without cache</span>
|
|
41
|
+
<span className="text-red-400 font-mono">{formatCost(wouldHavePaid)}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex items-center justify-between">
|
|
44
|
+
<span className="text-muted-foreground">You paid</span>
|
|
45
|
+
<span className="text-foreground font-mono">{formatCost(totalCost)}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center justify-between font-bold">
|
|
48
|
+
<span className="text-[#34d399]">Savings</span>
|
|
49
|
+
<span className="text-[#34d399] font-mono">{formatCost(totalSavings)}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<ResponsiveContainer width="100%" height={140}>
|
|
54
|
+
<PieChart>
|
|
55
|
+
<Pie
|
|
56
|
+
data={pieData}
|
|
57
|
+
cx="50%"
|
|
58
|
+
cy="50%"
|
|
59
|
+
innerRadius={35}
|
|
60
|
+
outerRadius={60}
|
|
61
|
+
dataKey="value"
|
|
62
|
+
strokeWidth={0}
|
|
63
|
+
>
|
|
64
|
+
{pieData.map((entry, i) => (
|
|
65
|
+
<Cell key={i} fill={entry.color} />
|
|
66
|
+
))}
|
|
67
|
+
</Pie>
|
|
68
|
+
<Tooltip
|
|
69
|
+
contentStyle={{ background: 'var(--card)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}
|
|
70
|
+
formatter={(val: number | undefined, name?: string) => [formatTokens(val ?? 0), name ?? '']}
|
|
71
|
+
/>
|
|
72
|
+
</PieChart>
|
|
73
|
+
</ResponsiveContainer>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ResponsiveContainer } from 'recharts'
|
|
4
|
+
import { formatCost } from '@/lib/decode'
|
|
5
|
+
import type { ProjectCost } from '@/types/claude'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
projects: ProjectCost[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CostByProjectChart({ projects }: Props) {
|
|
12
|
+
const top = projects.slice(0, 12)
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
<h3 className="text-[13px] font-bold text-muted-foreground uppercase tracking-widest mb-3">Cost by Project</h3>
|
|
17
|
+
<ResponsiveContainer width="100%" height={Math.max(120, top.length * 28)}>
|
|
18
|
+
<BarChart data={top} layout="vertical" margin={{ top: 0, right: 60, bottom: 0, left: 8 }}>
|
|
19
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
|
20
|
+
<XAxis
|
|
21
|
+
type="number"
|
|
22
|
+
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
|
23
|
+
tickLine={false}
|
|
24
|
+
axisLine={false}
|
|
25
|
+
tickFormatter={v => `$${v.toFixed(2)}`}
|
|
26
|
+
/>
|
|
27
|
+
<YAxis
|
|
28
|
+
type="category"
|
|
29
|
+
dataKey="display_name"
|
|
30
|
+
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
|
31
|
+
tickLine={false}
|
|
32
|
+
axisLine={false}
|
|
33
|
+
width={90}
|
|
34
|
+
/>
|
|
35
|
+
<Tooltip
|
|
36
|
+
contentStyle={{ background: 'var(--card)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}
|
|
37
|
+
formatter={(val: number | undefined) => [formatCost(val ?? 0), 'Estimated cost']}
|
|
38
|
+
/>
|
|
39
|
+
<Bar dataKey="estimated_cost" radius={[0, 3, 3, 0]}>
|
|
40
|
+
{top.map((_, i) => (
|
|
41
|
+
<Cell key={i} fill={i === 0 ? '#d97706' : '#d97706' + Math.max(30, 100 - i * 7).toString(16)} />
|
|
42
|
+
))}
|
|
43
|
+
</Bar>
|
|
44
|
+
</BarChart>
|
|
45
|
+
</ResponsiveContainer>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|